1435 lines
51 KiB
PHP
1435 lines
51 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
const APP_NAME = 'Seoul Control Center';
|
|
const APP_HOST = 'control.chaegeon.com';
|
|
const REMEMBER_COOKIE = 'control_remember';
|
|
const REMEMBER_DAYS = 30;
|
|
|
|
$controlSecretFile = '/home/seo/secret/control.php';
|
|
if (!is_file($controlSecretFile)) {
|
|
throw new RuntimeException('Missing control secret config: ' . $controlSecretFile);
|
|
}
|
|
|
|
$controlSecretConfig = require $controlSecretFile;
|
|
$controlDbConfig = is_array($controlSecretConfig['db'] ?? null) ? $controlSecretConfig['db'] : [];
|
|
$controlBatteryConfig = is_array($controlSecretConfig['battery'] ?? null) ? $controlSecretConfig['battery'] : [];
|
|
define('CONTROL_BATTERY_ENABLED', (bool)($controlBatteryConfig['enabled'] ?? false));
|
|
|
|
define('APP_PASSWORD', (string)($controlSecretConfig['app_password'] ?? ''));
|
|
define('DB_HOST', (string)($controlDbConfig['host'] ?? '127.0.0.1'));
|
|
define('DB_PORT', (int)($controlDbConfig['port'] ?? 3306));
|
|
define('DB_NAME', (string)($controlDbConfig['name'] ?? 'fanpanel'));
|
|
define('DB_USER', (string)($controlDbConfig['user'] ?? 'fanpanel'));
|
|
define('DB_PASS', (string)($controlDbConfig['pass'] ?? ''));
|
|
|
|
$batteryCellCapacityMah = max(0.0, (float)($controlBatteryConfig['cell_capacity_mah'] ?? 0.0));
|
|
$batteryParallelCells = max(1, (int)($controlBatteryConfig['parallel_cells'] ?? 1));
|
|
$batteryNominalVoltage = max(0.0, (float)($controlBatteryConfig['nominal_voltage'] ?? 0.0));
|
|
$batteryDerivedCapacityWh = ($batteryCellCapacityMah / 1000) * $batteryParallelCells * $batteryNominalVoltage;
|
|
|
|
define('BATTERY_CELL_CAPACITY_MAH', $batteryCellCapacityMah);
|
|
define('BATTERY_PARALLEL_CELLS', $batteryParallelCells);
|
|
define('BATTERY_CAPACITY_MAH', $batteryCellCapacityMah * $batteryParallelCells);
|
|
define('BATTERY_NOMINAL_VOLTAGE', $batteryNominalVoltage);
|
|
define('BATTERY_CAPACITY_WH', isset($controlBatteryConfig['capacity_wh']) ? max(0.0, (float)$controlBatteryConfig['capacity_wh']) : $batteryDerivedCapacityWh);
|
|
|
|
date_default_timezone_set('Asia/Seoul');
|
|
|
|
if (is_file(__DIR__ . '/../vendor/autoload.php')) {
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
}
|
|
|
|
function db_server(): PDO
|
|
{
|
|
return new PDO(
|
|
'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4',
|
|
DB_USER,
|
|
DB_PASS,
|
|
[
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]
|
|
);
|
|
}
|
|
|
|
function db(bool $forceReconnect = false): PDO
|
|
{
|
|
static $pdo = null;
|
|
static $lastPing = 0.0;
|
|
|
|
if ($forceReconnect) {
|
|
$pdo = null;
|
|
$lastPing = 0.0;
|
|
}
|
|
|
|
if ($pdo instanceof PDO) {
|
|
if ((microtime(true) - $lastPing) < 5.0) {
|
|
return $pdo;
|
|
}
|
|
|
|
try {
|
|
$pdo->query('SELECT 1');
|
|
$lastPing = microtime(true);
|
|
return $pdo;
|
|
} catch (PDOException $e) {
|
|
if (db_connection_lost($e)) {
|
|
$pdo = null;
|
|
$lastPing = 0.0;
|
|
} else {
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
|
|
bootstrap_db();
|
|
|
|
$pdo = db_connect();
|
|
$lastPing = microtime(true);
|
|
|
|
return $pdo;
|
|
}
|
|
|
|
function db_connect(): PDO
|
|
{
|
|
$pdo = new PDO(
|
|
'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4',
|
|
DB_USER,
|
|
DB_PASS,
|
|
[
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]
|
|
);
|
|
|
|
$pdo->exec("SET time_zone = '+09:00'");
|
|
|
|
return $pdo;
|
|
}
|
|
|
|
function db_connection_lost(PDOException $e): bool
|
|
{
|
|
$info = $e->errorInfo;
|
|
$driverCode = isset($info[1]) ? (int)$info[1] : 0;
|
|
$message = $e->getMessage();
|
|
|
|
return in_array($driverCode, [2006, 2013], true)
|
|
|| str_contains($message, 'server has gone away')
|
|
|| 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
|
|
{
|
|
$definitions = [
|
|
'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.allow_reboot' => ['group' => 'security', 'label' => '재부팅 실행 허용', 'type' => 'boolean', 'default' => 1],
|
|
'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.rp1_temp_weight' => ['group' => 'fan', 'label' => 'RP1 온도 반영 비율', 'type' => 'number', 'default' => 0.35, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
|
'fan.control_temp_headroom' => ['group' => 'fan', 'label' => '팬 판단 온도 여유', 'type' => 'number', 'default' => 1.5, 'min' => 0, 'max' => 8, 'step' => 0.1, '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'],
|
|
'notice.rpm_expected_pwm' => ['group' => 'notice', 'label' => 'RPM 감시 시작 PWM', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 255, 'step' => 1, 'unit' => 'PWM'],
|
|
'notice.log_during_alert' => ['group' => 'notice', 'label' => 'Alert 유지 중 반복 기록', 'type' => 'boolean', 'default' => 0],
|
|
'notice.log_interval_seconds' => ['group' => 'notice', 'label' => 'Alert 반복 기록 간격', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'],
|
|
'notice.downward_only_process_hide' => ['group' => 'notice', 'label' => '하강 변화 원인 후보 숨김', 'type' => 'boolean', 'default' => 1],
|
|
'display.process_limit' => ['group' => 'display', 'label' => '프로세스 후보 표시 수', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '개'],
|
|
'display.custom_service_log_lines' => ['group' => 'display', 'label' => '사용자 서비스 로그 줄 수', 'type' => 'number', 'default' => 12, 'min' => 1, 'max' => 50, 'step' => 1, 'unit' => '줄'],
|
|
'display.custom_service_cache_seconds' => ['group' => 'display', 'label' => '사용자 서비스 캐시 시간', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 60, 'step' => 1, 'unit' => '초'],
|
|
'display.fan_notice_history_limit' => ['group' => 'display', 'label' => '팬 이상 이력 표시 수', 'type' => 'number', 'default' => 100, 'min' => 10, 'max' => 500, 'step' => 10, 'unit' => '개'],
|
|
'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'],
|
|
'battery.runtime_cap_hours' => ['group' => 'battery', 'label' => '잔여시간 현실 상한', 'type' => 'number', 'default' => 18, 'min' => 1, 'max' => 48, 'step' => 0.5, 'unit' => '시간'],
|
|
'battery.minimum_system_watts' => ['group' => 'battery', 'label' => '최소 시스템 전력', 'type' => 'number', 'default' => 5.2, 'min' => 1.5, 'max' => 25, 'step' => 0.1, 'unit' => 'W'],
|
|
'battery.long_candidate_penalty' => ['group' => 'battery', 'label' => '상한 초과 후보 감쇠', 'type' => 'number', 'default' => 0.62, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
|
|
'battery.energy_model_enabled' => ['group' => 'battery', 'label' => '에너지 모델 사용', 'type' => 'boolean', 'default' => 1],
|
|
'battery.voltage_model_enabled' => ['group' => 'battery', 'label' => '전압 모델 사용', 'type' => 'boolean', 'default' => 1],
|
|
'battery.history_chart_limit' => ['group' => 'battery', 'label' => '상태 차트 표본 수', 'type' => 'number', 'default' => 240, 'min' => 60, 'max' => 1500, 'step' => 10, 'unit' => '개'],
|
|
'battery.history_sync_limit' => ['group' => 'battery', 'label' => '잔여시간 차트 표본 수', 'type' => 'number', 'default' => 240, 'min' => 60, 'max' => 1500, 'step' => 10, 'unit' => '개'],
|
|
];
|
|
|
|
if (!CONTROL_BATTERY_ENABLED) {
|
|
foreach (array_keys($definitions) as $key) {
|
|
if (str_starts_with($key, 'battery.')) {
|
|
unset($definitions[$key]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $definitions;
|
|
}
|
|
|
|
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' => '보안 정책',
|
|
'notify' => '알림 정책',
|
|
'display' => '화면/진단 표시',
|
|
'wifi' => 'WiFi 진단',
|
|
'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;
|
|
|
|
if ($done) {
|
|
return;
|
|
}
|
|
|
|
$pdo = db_server();
|
|
|
|
$pdo->exec("
|
|
CREATE DATABASE IF NOT EXISTS `" . DB_NAME . "`
|
|
CHARACTER SET utf8mb4
|
|
COLLATE utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("USE `" . DB_NAME . "`");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS control_state (
|
|
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
|
mode VARCHAR(16) NOT NULL DEFAULT 'auto',
|
|
manual_pwm INT NOT NULL DEFAULT 120,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS fan_actions (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
action_type VARCHAR(64) NOT NULL,
|
|
pwm_mode VARCHAR(16) NULL,
|
|
pwm_value INT NULL,
|
|
note TEXT NULL,
|
|
actor_ip VARCHAR(64) NULL,
|
|
success TINYINT(1) NOT NULL DEFAULT 1,
|
|
INDEX idx_created_at (created_at),
|
|
INDEX idx_action_type (action_type)
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS app_settings (
|
|
setting_key VARCHAR(96) NOT NULL PRIMARY KEY,
|
|
setting_value VARCHAR(255) NOT NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_updated_at (updated_at)
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS wifi_client_aliases (
|
|
mac VARCHAR(17) NOT NULL PRIMARY KEY,
|
|
hostname VARCHAR(255) NOT NULL,
|
|
note VARCHAR(255) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
ON UPDATE CURRENT_TIMESTAMP,
|
|
INDEX idx_updated_at (updated_at)
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS sensor_logs (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
recorded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
|
|
cpu_temp_c DECIMAL(6,2) NULL,
|
|
fan_rpm INT NULL,
|
|
fan_efficiency DECIMAL(10,2) NULL,
|
|
rp1_temp_c DECIMAL(6,2) NULL,
|
|
cpu_voltage DECIMAL(6,3) NULL,
|
|
cpu_watts DECIMAL(8,3) NULL,
|
|
battery_voltage DECIMAL(6,3) NULL,
|
|
battery_percent DECIMAL(6,2) NULL,
|
|
|
|
pwm_value INT NULL,
|
|
pwm_percent DECIMAL(5,2) NULL,
|
|
pwm_mode VARCHAR(16) NULL,
|
|
|
|
cpu_load_1 DECIMAL(8,2) NULL,
|
|
cpu_load_5 DECIMAL(8,2) NULL,
|
|
cpu_load_15 DECIMAL(8,2) NULL,
|
|
|
|
mem_total_mb INT NULL,
|
|
mem_used_mb INT NULL,
|
|
mem_free_mb INT NULL,
|
|
|
|
disk_total_kb BIGINT NULL,
|
|
disk_used_kb BIGINT NULL,
|
|
disk_free_kb BIGINT NULL,
|
|
|
|
uptime_seconds BIGINT NULL,
|
|
|
|
hostname VARCHAR(255) NULL,
|
|
create_ip VARCHAR(64) NULL,
|
|
|
|
INDEX idx_recorded_at (recorded_at),
|
|
INDEX idx_pwm_mode (pwm_mode)
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS fan_spike_logs (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
spike_key VARCHAR(32) NULL,
|
|
|
|
summary VARCHAR(255) NULL,
|
|
|
|
rpm_delta DECIMAL(10,2) NULL,
|
|
pwm_delta DECIMAL(10,2) NULL,
|
|
temp_delta DECIMAL(10,2) NULL,
|
|
|
|
current_rpm INT NULL,
|
|
current_pwm INT NULL,
|
|
current_temp DECIMAL(6,2) NULL,
|
|
|
|
cpu_process LONGTEXT NULL,
|
|
memory_process LONGTEXT NULL,
|
|
|
|
UNIQUE KEY uniq_spike_key (spike_key),
|
|
INDEX idx_created_at (created_at)
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS wifi_observed_sessions (
|
|
mac VARCHAR(17) NOT NULL PRIMARY KEY,
|
|
band VARCHAR(16) NOT NULL,
|
|
iface VARCHAR(32) NOT NULL,
|
|
first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
last_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
last_ip VARCHAR(64) NULL,
|
|
hostname VARCHAR(255) NULL,
|
|
INDEX idx_last_seen_at (last_seen_at),
|
|
INDEX idx_band (band)
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS remember_tokens (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
selector VARCHAR(64) NOT NULL,
|
|
validator_hash CHAR(64) NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL,
|
|
last_used_at DATETIME NULL,
|
|
last_ip VARCHAR(64) NULL,
|
|
user_agent VARCHAR(255) NULL,
|
|
|
|
UNIQUE KEY uniq_selector (selector),
|
|
INDEX idx_expires_at (expires_at)
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
CREATE TABLE IF NOT EXISTS system_notice_state (
|
|
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
|
state VARCHAR(16) NOT NULL DEFAULT 'normal',
|
|
|
|
baseline_temp DECIMAL(8,2) NULL,
|
|
baseline_rpm DECIMAL(12,2) NULL,
|
|
baseline_pwm DECIMAL(8,2) NULL,
|
|
|
|
active_reason VARCHAR(255) NULL,
|
|
active_temp_delta DECIMAL(8,2) NULL,
|
|
active_rpm_delta DECIMAL(12,2) NULL,
|
|
process_signature VARCHAR(255) NULL,
|
|
alert_started_at DATETIME NULL,
|
|
last_alert_at DATETIME NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB
|
|
DEFAULT CHARSET=utf8mb4
|
|
COLLATE=utf8mb4_unicode_ci
|
|
");
|
|
|
|
$pdo->exec("
|
|
INSERT IGNORE INTO control_state
|
|
(id, mode, manual_pwm)
|
|
VALUES
|
|
(1, 'auto', 120)
|
|
");
|
|
|
|
foreach ([
|
|
"ALTER TABLE sensor_logs ADD COLUMN create_ip VARCHAR(64) NULL",
|
|
"ALTER TABLE sensor_logs ADD COLUMN disk_total_kb BIGINT NULL AFTER mem_free_mb",
|
|
"ALTER TABLE sensor_logs ADD COLUMN disk_used_kb BIGINT NULL AFTER disk_total_kb",
|
|
"ALTER TABLE sensor_logs ADD COLUMN disk_free_kb BIGINT NULL AFTER disk_used_kb",
|
|
"ALTER TABLE sensor_logs ADD COLUMN fan_efficiency DECIMAL(10,2) NULL AFTER fan_rpm",
|
|
"ALTER TABLE sensor_logs ADD COLUMN rp1_temp_c DECIMAL(6,2) NULL AFTER fan_efficiency",
|
|
"ALTER TABLE sensor_logs ADD COLUMN cpu_voltage DECIMAL(6,3) NULL AFTER rp1_temp_c",
|
|
"ALTER TABLE sensor_logs ADD COLUMN cpu_watts DECIMAL(8,3) NULL AFTER cpu_voltage",
|
|
"ALTER TABLE sensor_logs ADD COLUMN battery_voltage DECIMAL(6,3) NULL AFTER cpu_watts",
|
|
"ALTER TABLE sensor_logs ADD COLUMN battery_percent DECIMAL(6,2) NULL AFTER battery_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN battery_current_amps",
|
|
"ALTER TABLE sensor_logs DROP COLUMN battery_watts",
|
|
"ALTER TABLE sensor_logs DROP COLUMN created_ip",
|
|
"ALTER TABLE fan_spike_logs ADD COLUMN spike_key VARCHAR(32) NULL AFTER created_at",
|
|
"ALTER TABLE fan_spike_logs ADD UNIQUE KEY uniq_spike_key (spike_key)",
|
|
"ALTER TABLE remember_tokens MODIFY selector VARCHAR(64) NOT NULL",
|
|
"ALTER TABLE system_notice_state ADD COLUMN active_temp_delta DECIMAL(8,2) NULL AFTER active_reason",
|
|
"ALTER TABLE system_notice_state ADD COLUMN active_rpm_delta DECIMAL(12,2) NULL AFTER active_temp_delta",
|
|
"ALTER TABLE system_notice_state ADD COLUMN process_signature VARCHAR(255) NULL AFTER active_rpm_delta",
|
|
"ALTER TABLE sensor_logs DROP COLUMN disk_total_gb",
|
|
"ALTER TABLE sensor_logs DROP COLUMN disk_used_gb",
|
|
"ALTER TABLE sensor_logs DROP COLUMN disk_free_gb",
|
|
"ALTER TABLE sensor_logs DROP COLUMN input_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN cpu_core_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN cpu_core_amps",
|
|
"ALTER TABLE sensor_logs DROP COLUMN system_watts",
|
|
"ALTER TABLE sensor_logs DROP COLUMN cpu_freq_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN cpu_freq_min_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN cpu_freq_max_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN arm_clock_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN core_clock_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN isp_clock_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN v3d_clock_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN emmc_clock_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN uart_clock_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN hdmi_clock_mhz",
|
|
"ALTER TABLE sensor_logs DROP COLUMN sdram_c_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN sdram_i_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN sdram_p_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in1_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in2_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in3_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN rp1_adc_in4_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN cooling_state",
|
|
"ALTER TABLE sensor_logs DROP COLUMN cooling_max_state",
|
|
"ALTER TABLE sensor_logs DROP COLUMN regulator_3v3_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN regulator_5v_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN regulator_vdd_3v3_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN regulator_wl_on",
|
|
"ALTER TABLE sensor_logs DROP COLUMN regulator_sd_io_voltage",
|
|
"ALTER TABLE sensor_logs DROP COLUMN pcie_link_speed",
|
|
"ALTER TABLE sensor_logs DROP COLUMN pcie_link_width",
|
|
"ALTER TABLE sensor_logs DROP COLUMN throttled_raw",
|
|
"ALTER TABLE sensor_logs DROP COLUMN under_voltage_now",
|
|
"ALTER TABLE sensor_logs DROP COLUMN throttled_now",
|
|
"ALTER TABLE sensor_logs DROP COLUMN freq_capped_now",
|
|
"ALTER TABLE sensor_logs DROP COLUMN soft_temp_limit_now",
|
|
"ALTER TABLE sensor_logs DROP COLUMN under_voltage_seen",
|
|
"ALTER TABLE sensor_logs DROP COLUMN throttled_seen",
|
|
"ALTER TABLE sensor_logs DROP COLUMN freq_capped_seen",
|
|
"ALTER TABLE sensor_logs DROP COLUMN soft_temp_limit_seen",
|
|
"ALTER TABLE sensor_logs DROP COLUMN ac_power_ok",
|
|
"ALTER TABLE sensor_logs DROP COLUMN charging_enabled",
|
|
"ALTER TABLE fan_spike_logs DROP COLUMN io_process",
|
|
"DROP TABLE top_process_logs",
|
|
"DROP TABLE wifi_station_logs",
|
|
"DROP TABLE software_status_logs",
|
|
"DROP TABLE helper_command_logs",
|
|
"DROP TABLE service_units",
|
|
"DROP TABLE failed_units",
|
|
"DROP TABLE timer_units",
|
|
"DROP TABLE journal_warning_logs",
|
|
"DROP TABLE installed_packages",
|
|
"DROP TABLE upgradable_packages",
|
|
] as $sql) {
|
|
try {
|
|
$pdo->exec($sql);
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
$done = true;
|
|
}
|
|
|
|
bootstrap_db();
|
|
|
|
function e(mixed $value): string
|
|
{
|
|
return htmlspecialchars(
|
|
(string)$value,
|
|
ENT_QUOTES | ENT_SUBSTITUTE,
|
|
'UTF-8'
|
|
);
|
|
}
|
|
|
|
function json_out(array $payload, int $status = 200): never
|
|
{
|
|
http_response_code($status);
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
|
|
echo json_encode(
|
|
$payload,
|
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
|
);
|
|
|
|
exit;
|
|
}
|
|
|
|
function signed_in(): bool
|
|
{
|
|
if (isset($_SESSION['control_login']) && $_SESSION['control_login'] === true) {
|
|
return true;
|
|
}
|
|
|
|
if (control_admin_session_from_rhymix_or_custom()) {
|
|
$_SESSION['control_login'] = true;
|
|
return true;
|
|
}
|
|
|
|
return auto_login_from_cookie();
|
|
}
|
|
|
|
function control_admin_session_from_rhymix_or_custom(): bool
|
|
{
|
|
static $checked = null;
|
|
if ($checked !== null) {
|
|
return $checked;
|
|
}
|
|
|
|
$checked = false;
|
|
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
|
|
if (!is_file($commonAuth) || !is_readable($commonAuth)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
require_once $commonAuth;
|
|
$checked = (function_exists('custom_auth_cookie_is_admin') && custom_auth_cookie_is_admin())
|
|
|| (function_exists('custom_is_site_admin') && custom_is_site_admin());
|
|
} catch (Throwable) {
|
|
$checked = false;
|
|
}
|
|
|
|
return $checked;
|
|
}
|
|
|
|
function clear_common_admin_auth_cookie_if_available(): void
|
|
{
|
|
$commonAuth = '/mnt/synology-web/custom/common/auth.php';
|
|
if (!is_file($commonAuth) || !is_readable($commonAuth)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
require_once $commonAuth;
|
|
if (function_exists('custom_clear_auth_cookie')) {
|
|
custom_clear_auth_cookie();
|
|
}
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
function require_login(): void
|
|
{
|
|
if (!signed_in()) {
|
|
header('Location: /');
|
|
exit;
|
|
}
|
|
}
|
|
|
|
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' => $secure,
|
|
'httponly' => true,
|
|
'samesite' => $sameSite,
|
|
];
|
|
}
|
|
|
|
function issue_remember_token(): void
|
|
{
|
|
$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
|
|
(
|
|
selector,
|
|
validator_hash,
|
|
expires_at,
|
|
last_ip,
|
|
user_agent
|
|
)
|
|
VALUES
|
|
(
|
|
:selector,
|
|
:validator_hash,
|
|
FROM_UNIXTIME(:expires_at),
|
|
:last_ip,
|
|
:user_agent
|
|
)
|
|
");
|
|
|
|
$stmt->execute([
|
|
':selector' => $selector,
|
|
':validator_hash' => hash('sha256', $validator),
|
|
':expires_at' => $expires,
|
|
':last_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
|
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
|
]);
|
|
|
|
setcookie(
|
|
REMEMBER_COOKIE,
|
|
$selector . ':' . $validator,
|
|
remember_cookie_options($expires)
|
|
);
|
|
|
|
if ((bool)setting_value('security.prune_expired_remember_tokens')) {
|
|
db()->exec("
|
|
DELETE FROM remember_tokens
|
|
WHERE expires_at < NOW()
|
|
");
|
|
}
|
|
}
|
|
|
|
function clear_remember_token(): void
|
|
{
|
|
$raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
|
|
$parts = explode(':', $raw, 2);
|
|
|
|
if (count($parts) === 2 && preg_match('/^[a-f0-9]{16,64}$/', $parts[0])) {
|
|
$stmt = db()->prepare("
|
|
DELETE FROM remember_tokens
|
|
WHERE selector = :selector
|
|
");
|
|
|
|
$stmt->execute([
|
|
':selector' => $parts[0],
|
|
]);
|
|
}
|
|
|
|
setcookie(
|
|
REMEMBER_COOKIE,
|
|
'',
|
|
remember_cookie_options(time() - 3600)
|
|
);
|
|
|
|
unset($_COOKIE[REMEMBER_COOKIE]);
|
|
}
|
|
|
|
function auto_login_from_cookie(): bool
|
|
{
|
|
$raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
|
|
|
|
if ($raw === '') {
|
|
return false;
|
|
}
|
|
|
|
$parts = explode(':', $raw, 2);
|
|
|
|
if (
|
|
count($parts) !== 2
|
|
|| !preg_match('/^[a-f0-9]{16,64}$/', $parts[0])
|
|
|| !preg_match('/^[a-f0-9]{32,128}$/', $parts[1])
|
|
) {
|
|
clear_remember_token();
|
|
return false;
|
|
}
|
|
|
|
[$selector, $validator] = $parts;
|
|
|
|
$stmt = db()->prepare("
|
|
SELECT
|
|
id,
|
|
validator_hash,
|
|
UNIX_TIMESTAMP(expires_at) AS expires_ts,
|
|
last_ip,
|
|
user_agent
|
|
FROM remember_tokens
|
|
WHERE selector = :selector
|
|
LIMIT 1
|
|
");
|
|
|
|
$stmt->execute([
|
|
':selector' => $selector,
|
|
]);
|
|
|
|
$row = $stmt->fetch();
|
|
|
|
if (!$row || (int)$row['expires_ts'] < time()) {
|
|
clear_remember_token();
|
|
return false;
|
|
}
|
|
|
|
if (!hash_equals((string)$row['validator_hash'], hash('sha256', $validator))) {
|
|
clear_remember_token();
|
|
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
|
|
SET
|
|
last_used_at = NOW(),
|
|
last_ip = :last_ip,
|
|
user_agent = :user_agent
|
|
WHERE id = :id
|
|
");
|
|
|
|
$stmt->execute([
|
|
':id' => $row['id'],
|
|
':last_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
|
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
function csrf_token(): string
|
|
{
|
|
if (empty($_SESSION['csrf'])) {
|
|
$_SESSION['csrf'] = bin2hex(random_bytes((int)setting_value('security.csrf_bytes')));
|
|
}
|
|
|
|
return (string)$_SESSION['csrf'];
|
|
}
|
|
|
|
function require_csrf(): void
|
|
{
|
|
$token = $_POST['csrf']
|
|
?? $_SERVER['HTTP_X_CSRF_TOKEN']
|
|
?? '';
|
|
|
|
if (!hash_equals(csrf_token(), (string)$token)) {
|
|
json_out([
|
|
'ok' => false,
|
|
'error' => 'bad_csrf',
|
|
], 403);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
$root
|
|
&& function_exists('posix_geteuid')
|
|
&& posix_geteuid() !== 0
|
|
) {
|
|
$full = array_merge(
|
|
['/usr/bin/sudo', '-n'],
|
|
$cmd
|
|
);
|
|
}
|
|
|
|
$escaped = array_map('escapeshellarg', $full);
|
|
|
|
$command =
|
|
'/usr/bin/timeout '
|
|
. (int)$timeout
|
|
. 's '
|
|
. implode(' ', $escaped)
|
|
. ' 2>&1';
|
|
|
|
$output = [];
|
|
$code = 0;
|
|
|
|
exec($command, $output, $code);
|
|
|
|
return [
|
|
'code' => $code,
|
|
'out' => implode("\n", $output),
|
|
];
|
|
}
|
|
|
|
function first_readable(array $files): string
|
|
{
|
|
foreach ($files as $file) {
|
|
if (!is_readable($file)) {
|
|
continue;
|
|
}
|
|
|
|
$value = trim((string)@file_get_contents($file));
|
|
|
|
if ($value !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function get_control_state(): array
|
|
{
|
|
$stmt = db()->query("
|
|
SELECT *
|
|
FROM control_state
|
|
WHERE id = 1
|
|
LIMIT 1
|
|
");
|
|
|
|
$row = $stmt->fetch() ?: [];
|
|
|
|
return [
|
|
'mode' => $row['mode'] ?? 'auto',
|
|
'manual_pwm' => isset($row['manual_pwm']) ? (int)$row['manual_pwm'] : 120,
|
|
'updated_at' => $row['updated_at'] ?? null,
|
|
];
|
|
}
|
|
|
|
function set_control_state(string $mode, int $manualPwm): void
|
|
{
|
|
if (!in_array($mode, ['auto', 'manual', 'off'], true)) {
|
|
$mode = 'auto';
|
|
}
|
|
|
|
$manualPwm = max(0, min(255, $manualPwm));
|
|
|
|
$stmt = db()->prepare("
|
|
INSERT INTO control_state
|
|
(id, mode, manual_pwm)
|
|
VALUES
|
|
(1, :mode, :manual_pwm)
|
|
ON DUPLICATE KEY UPDATE
|
|
mode = VALUES(mode),
|
|
manual_pwm = VALUES(manual_pwm),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
");
|
|
|
|
$stmt->execute([
|
|
':mode' => $mode,
|
|
':manual_pwm' => $manualPwm,
|
|
]);
|
|
}
|
|
|
|
function rp1_temp_c(): ?float
|
|
{
|
|
foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) {
|
|
if (trim(first_readable([$dir . '/name'])) !== 'rp1_adc') {
|
|
continue;
|
|
}
|
|
|
|
$raw = first_readable([$dir . '/temp1_input']);
|
|
if ($raw !== '' && is_numeric($raw)) {
|
|
return round(((float)$raw) / 1000, 2);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function cpu_power_status(): array
|
|
{
|
|
$out = sh(['/usr/bin/vcgencmd', 'pmic_read_adc'], false, 3)['out'];
|
|
$voltage = null;
|
|
$amps = null;
|
|
|
|
foreach (preg_split('/\R/', trim($out)) ?: [] as $line) {
|
|
if (preg_match('/VDD_CORE_V\\s+[^=]+=([-+]?[0-9]*\\.?[0-9]+)V/', $line, $m)) {
|
|
$voltage = (float)$m[1];
|
|
} elseif (preg_match('/VDD_CORE_A\\s+[^=]+=([-+]?[0-9]*\\.?[0-9]+)A/', $line, $m)) {
|
|
$amps = (float)$m[1];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'voltage' => $voltage === null ? null : round($voltage, 3),
|
|
'amps' => $amps === null ? null : round($amps, 3),
|
|
'watts' => ($voltage === null || $amps === null) ? null : round($voltage * $amps, 3),
|
|
];
|
|
}
|
|
|
|
function i2cget_byte(int $bus, int $address, int $register): ?int
|
|
{
|
|
$bin = null;
|
|
|
|
foreach (['/usr/sbin/i2cget', '/usr/bin/i2cget'] as $candidate) {
|
|
if (is_executable($candidate)) {
|
|
$bin = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($bin === null) {
|
|
return null;
|
|
}
|
|
|
|
$result = sh([
|
|
$bin,
|
|
'-y',
|
|
(string)$bus,
|
|
sprintf('0x%02x', $address),
|
|
sprintf('0x%02x', $register),
|
|
], true, 2);
|
|
|
|
if ((int)$result['code'] !== 0) {
|
|
return null;
|
|
}
|
|
|
|
$raw = trim((string)$result['out']);
|
|
if (!preg_match('/0x([0-9a-f]{1,2})/i', $raw, $m)) {
|
|
return null;
|
|
}
|
|
|
|
return hexdec($m[1]);
|
|
}
|
|
|
|
function i2cget_word_bytes(int $bus, int $address, int $register): ?array
|
|
{
|
|
$msb = i2cget_byte($bus, $address, $register);
|
|
$lsb = i2cget_byte($bus, $address, $register + 1);
|
|
|
|
if ($msb === null || $lsb === null) {
|
|
return null;
|
|
}
|
|
|
|
return [$msb, $lsb];
|
|
}
|
|
|
|
function battery_status(): array
|
|
{
|
|
if (!CONTROL_BATTERY_ENABLED) {
|
|
return [
|
|
'enabled' => false,
|
|
'removed' => true,
|
|
'voltage' => null,
|
|
'percent' => null,
|
|
];
|
|
}
|
|
|
|
$voltage = null;
|
|
$percent = null;
|
|
|
|
$vcell = i2cget_word_bytes(1, 0x36, 0x02);
|
|
if ($vcell !== null) {
|
|
[$msb, $lsb] = $vcell;
|
|
$raw = ($msb << 4) | ($lsb >> 4);
|
|
$voltage = round($raw * 0.00125, 3);
|
|
}
|
|
|
|
$soc = i2cget_word_bytes(1, 0x36, 0x04);
|
|
if ($soc !== null) {
|
|
[$msb, $lsb] = $soc;
|
|
$percent = round((($msb << 8) | $lsb) / 256, 2);
|
|
}
|
|
|
|
return [
|
|
'enabled' => true,
|
|
'removed' => false,
|
|
'voltage' => $voltage,
|
|
'percent' => $percent,
|
|
];
|
|
}
|
|
|
|
function fan_efficiency(?int $rpm, ?float $cpuTemp, ?float $cpuWatts): ?int
|
|
{
|
|
if (
|
|
$rpm === null
|
|
|| $cpuTemp === null
|
|
|| $cpuWatts === null
|
|
|| $cpuWatts <= 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if ($rpm <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$ambientTemp = 25.0;
|
|
$maxRpm = 10000.0;
|
|
|
|
$fanRatio = max(0.0, min(1.0, $rpm / $maxRpm));
|
|
$deltaTemp = max(0.1, $cpuTemp - $ambientTemp);
|
|
$thermalResistance = $deltaTemp / max($cpuWatts, 0.1);
|
|
|
|
// 핵심: °C/W 낮을수록 좋음
|
|
if ($thermalResistance <= 6.0) {
|
|
$thermalScore = 100;
|
|
} elseif ($thermalResistance <= 7.0) {
|
|
$thermalScore = 95;
|
|
} elseif ($thermalResistance <= 8.0) {
|
|
$thermalScore = 88;
|
|
} elseif ($thermalResistance <= 9.0) {
|
|
$thermalScore = 80;
|
|
} elseif ($thermalResistance <= 10.0) {
|
|
$thermalScore = 72;
|
|
} elseif ($thermalResistance <= 11.0) {
|
|
$thermalScore = 64;
|
|
} elseif ($thermalResistance <= 12.0) {
|
|
$thermalScore = 56;
|
|
} elseif ($thermalResistance <= 13.0) {
|
|
$thermalScore = 48;
|
|
} elseif ($thermalResistance <= 14.0) {
|
|
$thermalScore = 40;
|
|
} elseif ($thermalResistance <= 16.0) {
|
|
$thermalScore = 30;
|
|
} elseif ($thermalResistance <= 18.0) {
|
|
$thermalScore = 20;
|
|
} else {
|
|
$thermalScore = 10;
|
|
}
|
|
|
|
// 목표 온도: 58~65°C
|
|
if ($cpuTemp < 45.0) {
|
|
$tempScore = 70;
|
|
} elseif ($cpuTemp < 50.0) {
|
|
$tempScore = 80;
|
|
} elseif ($cpuTemp < 55.0) {
|
|
$tempScore = 90;
|
|
} elseif ($cpuTemp < 58.0) {
|
|
$tempScore = 96;
|
|
} elseif ($cpuTemp < 65.0) {
|
|
$tempScore = 100;
|
|
} elseif ($cpuTemp < 70.0) {
|
|
$tempScore = 88;
|
|
} elseif ($cpuTemp < 75.0) {
|
|
$tempScore = 72;
|
|
} elseif ($cpuTemp < 80.0) {
|
|
$tempScore = 52;
|
|
} elseif ($cpuTemp < 85.0) {
|
|
$tempScore = 30;
|
|
} else {
|
|
$tempScore = 10;
|
|
}
|
|
|
|
// RPM은 비용. 단, 고온에서 고RPM은 정상이라 과하게 깎지 않음.
|
|
if ($fanRatio < 0.20) {
|
|
$fanScore = 85;
|
|
} elseif ($fanRatio < 0.40) {
|
|
$fanScore = 100;
|
|
} elseif ($fanRatio < 0.60) {
|
|
$fanScore = 92;
|
|
} elseif ($fanRatio < 0.75) {
|
|
$fanScore = 82;
|
|
} elseif ($fanRatio < 0.90) {
|
|
$fanScore = 70;
|
|
} else {
|
|
$fanScore = 58;
|
|
}
|
|
|
|
$penalty = 0.0;
|
|
$bonus = 0.0;
|
|
|
|
// 고RPM인데도 온도가 높으면 비효율
|
|
if ($fanRatio >= 0.85 && $cpuTemp >= 80.0) {
|
|
$penalty += 18;
|
|
} elseif ($fanRatio >= 0.75 && $cpuTemp >= 75.0) {
|
|
$penalty += 10;
|
|
}
|
|
|
|
// 저부하인데 팬이 너무 높으면 낭비
|
|
if ($cpuWatts < 2.0 && $fanRatio >= 0.70) {
|
|
$penalty += 12;
|
|
} elseif ($cpuWatts < 3.0 && $fanRatio >= 0.85) {
|
|
$penalty += 8;
|
|
}
|
|
|
|
// 차가운데 팬이 높으면 과냉각
|
|
if ($cpuTemp < 55.0 && $fanRatio >= 0.70) {
|
|
$penalty += 12;
|
|
}
|
|
|
|
// 고부하인데 온도 잘 잡으면 보너스
|
|
if ($cpuWatts >= 5.0 && $cpuTemp <= 68.0 && $fanRatio <= 0.75) {
|
|
$bonus += 8;
|
|
} elseif ($cpuWatts >= 4.0 && $cpuTemp <= 65.0 && $fanRatio <= 0.65) {
|
|
$bonus += 5;
|
|
}
|
|
|
|
$score =
|
|
($thermalScore * 0.65)
|
|
+ ($tempScore * 0.25)
|
|
+ ($fanScore * 0.10)
|
|
- $penalty
|
|
+ $bonus;
|
|
|
|
return max(0, min(100, (int)round($score)));
|
|
}
|
|
|
|
function add_fan_action(
|
|
string $type,
|
|
?string $mode,
|
|
?int $pwm,
|
|
?string $note,
|
|
bool $success = true
|
|
): void {
|
|
$stmt = db()->prepare("
|
|
INSERT INTO fan_actions
|
|
(
|
|
action_type,
|
|
pwm_mode,
|
|
pwm_value,
|
|
note,
|
|
actor_ip,
|
|
success
|
|
)
|
|
VALUES
|
|
(
|
|
:action_type,
|
|
:pwm_mode,
|
|
:pwm_value,
|
|
:note,
|
|
:actor_ip,
|
|
:success
|
|
)
|
|
");
|
|
|
|
$stmt->execute([
|
|
':action_type' => $type,
|
|
':pwm_mode' => $mode,
|
|
':pwm_value' => $pwm,
|
|
':note' => $note,
|
|
':actor_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
|
':success' => $success ? 1 : 0,
|
|
]);
|
|
}
|
|
|
|
function add_sensor_log(array $r): void
|
|
{
|
|
$stmt = db()->prepare("
|
|
INSERT INTO sensor_logs
|
|
(
|
|
cpu_temp_c,
|
|
fan_rpm,
|
|
fan_efficiency,
|
|
rp1_temp_c,
|
|
cpu_voltage,
|
|
cpu_watts,
|
|
battery_voltage,
|
|
battery_percent,
|
|
|
|
pwm_value,
|
|
pwm_percent,
|
|
pwm_mode,
|
|
|
|
cpu_load_1,
|
|
cpu_load_5,
|
|
cpu_load_15,
|
|
|
|
mem_total_mb,
|
|
mem_used_mb,
|
|
mem_free_mb,
|
|
|
|
disk_total_kb,
|
|
disk_used_kb,
|
|
disk_free_kb,
|
|
|
|
uptime_seconds,
|
|
|
|
hostname,
|
|
create_ip
|
|
)
|
|
VALUES
|
|
(
|
|
:cpu_temp_c,
|
|
:fan_rpm,
|
|
:fan_efficiency,
|
|
:rp1_temp_c,
|
|
:cpu_voltage,
|
|
:cpu_watts,
|
|
:battery_voltage,
|
|
:battery_percent,
|
|
|
|
:pwm_value,
|
|
:pwm_percent,
|
|
:pwm_mode,
|
|
|
|
:cpu_load_1,
|
|
:cpu_load_5,
|
|
:cpu_load_15,
|
|
|
|
:mem_total_mb,
|
|
:mem_used_mb,
|
|
:mem_free_mb,
|
|
|
|
:disk_total_kb,
|
|
:disk_used_kb,
|
|
:disk_free_kb,
|
|
|
|
:uptime_seconds,
|
|
|
|
:hostname,
|
|
:create_ip
|
|
)
|
|
");
|
|
|
|
$stmt->execute([
|
|
':cpu_temp_c' => $r['cpu_temp_c'] ?? null,
|
|
':fan_rpm' => $r['fan_rpm'] ?? null,
|
|
':fan_efficiency' => $r['fan_efficiency'] ?? null,
|
|
':rp1_temp_c' => $r['rp1_temp_c'] ?? null,
|
|
':cpu_voltage' => $r['cpu_voltage'] ?? null,
|
|
':cpu_watts' => $r['cpu_watts'] ?? null,
|
|
':battery_voltage' => $r['battery_voltage'] ?? null,
|
|
':battery_percent' => $r['battery_percent'] ?? null,
|
|
|
|
':pwm_value' => $r['pwm_value'] ?? null,
|
|
':pwm_percent' => $r['pwm_percent'] ?? null,
|
|
':pwm_mode' => $r['pwm_mode'] ?? null,
|
|
|
|
':cpu_load_1' => $r['cpu_load_1'] ?? null,
|
|
':cpu_load_5' => $r['cpu_load_5'] ?? null,
|
|
':cpu_load_15' => $r['cpu_load_15'] ?? null,
|
|
|
|
':mem_total_mb' => $r['mem_total_mb'] ?? null,
|
|
':mem_used_mb' => $r['mem_used_mb'] ?? null,
|
|
':mem_free_mb' => $r['mem_free_mb'] ?? null,
|
|
|
|
':disk_total_kb' => $r['disk_total_kb'] ?? null,
|
|
':disk_used_kb' => $r['disk_used_kb'] ?? null,
|
|
':disk_free_kb' => $r['disk_free_kb'] ?? null,
|
|
|
|
':uptime_seconds' => $r['uptime_seconds'] ?? null,
|
|
|
|
':hostname' => $r['hostname'] ?? null,
|
|
':create_ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
|
]);
|
|
}
|