Control 보안 정책 설정 확장
This commit is contained in:
@@ -12,7 +12,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
|
|
||||||
- 팬 모드 `auto`, `manual`, `off` 제어
|
- 팬 모드 `auto`, `manual`, `off` 제어
|
||||||
- PWM slider 기반 수동 팬 제어
|
- PWM slider 기반 수동 팬 제어
|
||||||
- 오버레이 설정 모달에서 팬 자동곡선, 이벤트 판정, 팬 이상감지, 배터리 예측 파라미터 조정
|
- 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지, 배터리 예측 파라미터 조정
|
||||||
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
|
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
|
||||||
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
|
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
|
||||||
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 사용하고, CPU 전력 변화로 현재 부하를 보정
|
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 사용하고, CPU 전력 변화로 현재 부하를 보정
|
||||||
@@ -111,6 +111,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과
|
|||||||
설정 가능한 항목은 다음 범위입니다.
|
설정 가능한 항목은 다음 범위입니다.
|
||||||
|
|
||||||
- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step
|
- 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step
|
||||||
|
- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부
|
||||||
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
- 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수
|
||||||
- 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이
|
- 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이
|
||||||
- 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치
|
- 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치
|
||||||
@@ -182,7 +183,7 @@ control-wifi-observe.service
|
|||||||
7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
|
7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다.
|
||||||
8. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
|
8. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다.
|
||||||
9. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
9. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
|
||||||
10. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
10. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
||||||
11. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
|
11. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
|
||||||
12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
||||||
13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다.
|
13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다.
|
||||||
@@ -236,7 +237,10 @@ control-wifi-observe.service
|
|||||||
- 로그인 세션과 CSRF token을 사용합니다.
|
- 로그인 세션과 CSRF token을 사용합니다.
|
||||||
- POST 조작 API는 CSRF 검증을 통과해야 합니다.
|
- POST 조작 API는 CSRF 검증을 통과해야 합니다.
|
||||||
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
|
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
|
||||||
- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다.
|
- 재부팅 API는 CSRF, 로그인 세션, 설정된 확인 문구, 관리자 암호 재검증을 모두 요구합니다.
|
||||||
|
- 로그인 화면은 CSRF 검사를 수행하고, 설정한 실패 횟수/집계 시간/잠금 시간에 따라 세션 단위 잠금을 적용합니다.
|
||||||
|
- Remember login은 설정에 따라 Secure/SameSite, 토큰 길이, 만료 기간, User-Agent 고정, IPv4 접두사 고정을 조정할 수 있습니다.
|
||||||
|
- WiFi restart/reload 허용 여부와 root 명령 timeout은 설정 모달에서 조정합니다.
|
||||||
- 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다.
|
- 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다.
|
||||||
- HA webhook id는 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`에 둡니다. 값이 없으면 발송하지 않고 `ha_notify_logs`에 실패로 기록합니다.
|
- HA webhook id는 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`에 둡니다. 값이 없으면 발송하지 않고 `ha_notify_logs`에 실패로 기록합니다.
|
||||||
|
|
||||||
|
|||||||
+268
-14
@@ -124,6 +124,223 @@ function db_connection_lost(PDOException $e): bool
|
|||||||
|| str_contains($message, 'Lost connection');
|
|| 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
|
function bootstrap_db(): void
|
||||||
{
|
{
|
||||||
static $done = false;
|
static $done = false;
|
||||||
@@ -884,21 +1101,34 @@ function require_login(): void
|
|||||||
|
|
||||||
function remember_cookie_options(int $expires): array
|
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 [
|
return [
|
||||||
'expires' => $expires,
|
'expires' => $expires,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'domain' => '',
|
'domain' => '',
|
||||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
'secure' => $secure,
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => $sameSite,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function issue_remember_token(): void
|
function issue_remember_token(): void
|
||||||
{
|
{
|
||||||
$selector = bin2hex(random_bytes(12));
|
$selectorBytes = (int)setting_value('security.remember_selector_bytes');
|
||||||
$validator = bin2hex(random_bytes(32));
|
$validatorBytes = (int)setting_value('security.remember_validator_bytes');
|
||||||
$expires = time() + REMEMBER_DAYS * 86400;
|
$selector = bin2hex(random_bytes($selectorBytes));
|
||||||
|
$validator = bin2hex(random_bytes($validatorBytes));
|
||||||
|
$expires = time() + ((int)setting_value('security.remember_days') * 86400);
|
||||||
|
|
||||||
$stmt = db()->prepare("
|
$stmt = db()->prepare("
|
||||||
INSERT INTO remember_tokens
|
INSERT INTO remember_tokens
|
||||||
@@ -933,10 +1163,12 @@ function issue_remember_token(): void
|
|||||||
remember_cookie_options($expires)
|
remember_cookie_options($expires)
|
||||||
);
|
);
|
||||||
|
|
||||||
db()->exec("
|
if ((bool)setting_value('security.prune_expired_remember_tokens')) {
|
||||||
DELETE FROM remember_tokens
|
db()->exec("
|
||||||
WHERE expires_at < NOW()
|
DELETE FROM remember_tokens
|
||||||
");
|
WHERE expires_at < NOW()
|
||||||
|
");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear_remember_token(): void
|
function clear_remember_token(): void
|
||||||
@@ -944,7 +1176,7 @@ function clear_remember_token(): void
|
|||||||
$raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
|
$raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
|
||||||
$parts = explode(':', $raw, 2);
|
$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("
|
$stmt = db()->prepare("
|
||||||
DELETE FROM remember_tokens
|
DELETE FROM remember_tokens
|
||||||
WHERE selector = :selector
|
WHERE selector = :selector
|
||||||
@@ -976,8 +1208,8 @@ function auto_login_from_cookie(): bool
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
count($parts) !== 2
|
count($parts) !== 2
|
||||||
|| !preg_match('/^[a-f0-9]{24}$/', $parts[0])
|
|| !preg_match('/^[a-f0-9]{16,64}$/', $parts[0])
|
||||||
|| !preg_match('/^[a-f0-9]{64}$/', $parts[1])
|
|| !preg_match('/^[a-f0-9]{32,128}$/', $parts[1])
|
||||||
) {
|
) {
|
||||||
clear_remember_token();
|
clear_remember_token();
|
||||||
return false;
|
return false;
|
||||||
@@ -989,7 +1221,9 @@ function auto_login_from_cookie(): bool
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
validator_hash,
|
validator_hash,
|
||||||
UNIX_TIMESTAMP(expires_at) AS expires_ts
|
UNIX_TIMESTAMP(expires_at) AS expires_ts,
|
||||||
|
last_ip,
|
||||||
|
user_agent
|
||||||
FROM remember_tokens
|
FROM remember_tokens
|
||||||
WHERE selector = :selector
|
WHERE selector = :selector
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -1011,7 +1245,23 @@ function auto_login_from_cookie(): bool
|
|||||||
return false;
|
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;
|
$_SESSION['control_login'] = true;
|
||||||
|
if ((bool)setting_value('security.session_regenerate_auto_login') && PHP_SAPI !== 'cli') {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = db()->prepare("
|
$stmt = db()->prepare("
|
||||||
UPDATE remember_tokens
|
UPDATE remember_tokens
|
||||||
@@ -1034,7 +1284,7 @@ function auto_login_from_cookie(): bool
|
|||||||
function csrf_token(): string
|
function csrf_token(): string
|
||||||
{
|
{
|
||||||
if (empty($_SESSION['csrf'])) {
|
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'];
|
return (string)$_SESSION['csrf'];
|
||||||
@@ -1056,6 +1306,10 @@ function require_csrf(): void
|
|||||||
|
|
||||||
function sh(array $cmd, bool $root = false, int $timeout = 8): array
|
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;
|
$full = $cmd;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
+6
-146
@@ -72,144 +72,6 @@ function human_remaining_seconds(int $seconds): string
|
|||||||
return sprintf('%dm', max(1, $minutes));
|
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
|
function dmesg_log(): array
|
||||||
{
|
{
|
||||||
$path = '/tmp/dmesg.log';
|
$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);
|
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
|
function weighted_linear_regression(array $points): ?array
|
||||||
{
|
{
|
||||||
$weightSum = 0.0;
|
$weightSum = 0.0;
|
||||||
@@ -3524,8 +3381,9 @@ function control_api_dispatch(): void
|
|||||||
if ($action === 'reboot') {
|
if ($action === 'reboot') {
|
||||||
$phrase = trim((string)($_POST['phrase'] ?? ''));
|
$phrase = trim((string)($_POST['phrase'] ?? ''));
|
||||||
$password = (string)($_POST['password'] ?? '');
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
$expectedPhrase = (string)setting_value('security.reboot_phrase');
|
||||||
|
|
||||||
if ($phrase !== '재부팅') {
|
if ($phrase !== $expectedPhrase) {
|
||||||
json_out([
|
json_out([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => 'bad_reboot_phrase',
|
'error' => 'bad_reboot_phrase',
|
||||||
@@ -3549,7 +3407,7 @@ function control_api_dispatch(): void
|
|||||||
true
|
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) {
|
if ($result['code'] !== 0) {
|
||||||
json_out([
|
json_out([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
@@ -3578,6 +3436,8 @@ function control_api_dispatch(): void
|
|||||||
if (
|
if (
|
||||||
!in_array($unit, $allowedUnits, true)
|
!in_array($unit, $allowedUnits, true)
|
||||||
|| !in_array($verb, ['restart', 'reload'], 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([
|
json_out([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
@@ -3585,7 +3445,7 @@ function control_api_dispatch(): void
|
|||||||
], 400);
|
], 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'];
|
$out = $result['out'];
|
||||||
|
|
||||||
add_fan_action(
|
add_fan_action(
|
||||||
|
|||||||
+72
-12
@@ -616,6 +616,54 @@
|
|||||||
if (!byGroup[group]) byGroup[group] = [];
|
if (!byGroup[group]) byGroup[group] = [];
|
||||||
byGroup[group].push(item);
|
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 `
|
||||||
|
<span class="settings-toggle">
|
||||||
|
<input data-setting-key="${key}" type="checkbox" value="1" ${checked ? 'checked' : ''}>
|
||||||
|
<span>${checked ? 'ON' : 'OFF'}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type === 'select') {
|
||||||
|
const options = item.options || {};
|
||||||
|
return `
|
||||||
|
<select data-setting-key="${key}" class="settings-select">
|
||||||
|
${Object.entries(options).map(([optionValue, label]) => `
|
||||||
|
<option value="${escapeHtml(optionValue)}" ${String(optionValue) === String(value) ? 'selected' : ''}>${escapeHtml(label)}</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (type === 'text') {
|
||||||
|
return `
|
||||||
|
<input
|
||||||
|
data-setting-key="${key}"
|
||||||
|
type="text"
|
||||||
|
maxlength="${escapeHtml(item.max_length ?? 255)}"
|
||||||
|
value="${escapeHtml(value)}">
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<input
|
||||||
|
data-setting-key="${key}"
|
||||||
|
type="number"
|
||||||
|
min="${escapeHtml(item.min ?? '')}"
|
||||||
|
max="${escapeHtml(item.max ?? '')}"
|
||||||
|
step="${escapeHtml(item.step ?? '1')}"
|
||||||
|
value="${escapeHtml(value)}">
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
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]) => `
|
els.settingsBody.innerHTML = Object.entries(byGroup).map(([group, items]) => `
|
||||||
<section class="settings-group">
|
<section class="settings-group">
|
||||||
@@ -628,20 +676,29 @@
|
|||||||
<span>${escapeHtml(item.unit || '')}</span>
|
<span>${escapeHtml(item.unit || '')}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="settings-control">
|
<span class="settings-control">
|
||||||
<input
|
${fieldHtml(item)}
|
||||||
data-setting-key="${escapeHtml(item.key)}"
|
<span class="settings-default">${t('settingsDefault')} ${escapeHtml(defaultValueText(item))}</span>
|
||||||
type="number"
|
|
||||||
min="${escapeHtml(item.min ?? '')}"
|
|
||||||
max="${escapeHtml(item.max ?? '')}"
|
|
||||||
step="${escapeHtml(item.step ?? '1')}"
|
|
||||||
value="${escapeHtml(item.value ?? item.default ?? '')}">
|
|
||||||
<span class="settings-default">${t('settingsDefault')} ${escapeHtml(item.default ?? '')}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`).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() {
|
async function openSettings() {
|
||||||
@@ -668,7 +725,9 @@
|
|||||||
function currentSettingsFormValues() {
|
function currentSettingsFormValues() {
|
||||||
const values = {};
|
const values = {};
|
||||||
els.settingsBody?.querySelectorAll('[data-setting-key]').forEach(input => {
|
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;
|
return values;
|
||||||
}
|
}
|
||||||
@@ -1906,12 +1965,13 @@
|
|||||||
title: t('rebootTitle'),
|
title: t('rebootTitle'),
|
||||||
okText: t('next'),
|
okText: t('next'),
|
||||||
danger: true,
|
danger: true,
|
||||||
placeholder: t('rebootPhrase'),
|
placeholder: String(settingByKey('security.reboot_phrase', t('rebootPhrase'))),
|
||||||
autocomplete: 'off',
|
autocomplete: 'off',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (phrase === null) return;
|
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'), {
|
await window.customAlert(t('rebootPhraseMismatch'), {
|
||||||
title: t('rebootCanceled'),
|
title: t('rebootCanceled'),
|
||||||
danger: true,
|
danger: true,
|
||||||
@@ -1932,7 +1992,7 @@
|
|||||||
els.rebootBtn.disabled = true;
|
els.rebootBtn.disabled = true;
|
||||||
notice(t('rebootSending'), 'info');
|
notice(t('rebootSending'), 'info');
|
||||||
await api('reboot', {
|
await api('reboot', {
|
||||||
phrase: '재부팅',
|
phrase: expectedPhrase,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
await window.customAlert(t('rebootStartedBody'), {
|
await window.customAlert(t('rebootStartedBody'), {
|
||||||
|
|||||||
+27
-4
@@ -6,6 +6,8 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
|
|||||||
}
|
}
|
||||||
require_once __DIR__ . '/../config/config.php';
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$csrf = csrf_token();
|
||||||
|
|
||||||
if (isset($_GET['logout'])) {
|
if (isset($_GET['logout'])) {
|
||||||
clear_remember_token();
|
clear_remember_token();
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
@@ -20,9 +22,24 @@ if (isset($_GET['logout'])) {
|
|||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) {
|
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_regenerate_id(true);
|
||||||
$_SESSION['control_login'] = true;
|
$_SESSION['control_login'] = true;
|
||||||
|
unset($_SESSION['login_failures'], $_SESSION['login_locked_until']);
|
||||||
if (!empty($_POST['remember_login'])) {
|
if (!empty($_POST['remember_login'])) {
|
||||||
issue_remember_token();
|
issue_remember_token();
|
||||||
} else {
|
} else {
|
||||||
@@ -30,12 +47,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) {
|
|||||||
}
|
}
|
||||||
header('Location: /');
|
header('Location: /');
|
||||||
exit;
|
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();
|
$loggedIn = signed_in();
|
||||||
$csrf = csrf_token();
|
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -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)}
|
.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}
|
.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}
|
.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: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: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)}}
|
@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}
|
|||||||
<h1><?= e(APP_NAME) ?></h1>
|
<h1><?= e(APP_NAME) ?></h1>
|
||||||
<p>Fan and WiFi control panel</p>
|
<p>Fan and WiFi control panel</p>
|
||||||
<input type="password" name="login_password" class="input" placeholder="Password" autofocus>
|
<input type="password" name="login_password" class="input" placeholder="Password" autofocus>
|
||||||
|
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
|
||||||
<label class="remember-row">
|
<label class="remember-row">
|
||||||
<input type="checkbox" name="remember_login" value="1" checked>
|
<input type="checkbox" name="remember_login" value="1" checked>
|
||||||
<span>자동로그인 유지</span>
|
<span>자동로그인 유지</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user