Control 보안 정책 설정 확장

This commit is contained in:
seo
2026-06-26 03:14:09 +09:00
parent 772984d9f0
commit 7a9d4af9ad
5 changed files with 380 additions and 179 deletions
+268 -14
View File
@@ -124,6 +124,223 @@ function db_connection_lost(PDOException $e): bool
|| str_contains($message, 'Lost connection');
}
function clamp_float(float $value, float $min, float $max): float
{
return max($min, min($max, $value));
}
function setting_definitions(): array
{
return [
'security.remember_days' => ['group' => 'security', 'label' => '자동 로그인 유지 기간', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365, 'step' => 1, 'unit' => '일'],
'security.cookie_secure_mode' => ['group' => 'security', 'label' => 'Remember 쿠키 Secure', 'type' => 'select', 'default' => 'auto', 'options' => ['auto' => 'HTTPS일 때 자동', 'always' => '항상 사용', 'never' => '사용 안함']],
'security.cookie_samesite' => ['group' => 'security', 'label' => 'Remember 쿠키 SameSite', 'type' => 'select', 'default' => 'Lax', 'options' => ['Lax' => 'Lax', 'Strict' => 'Strict', 'None' => 'None']],
'security.csrf_bytes' => ['group' => 'security', 'label' => 'CSRF 토큰 바이트', 'type' => 'number', 'default' => 24, 'min' => 16, 'max' => 64, 'step' => 1, 'unit' => 'bytes'],
'security.remember_selector_bytes' => ['group' => 'security', 'label' => 'Remember selector 바이트', 'type' => 'number', 'default' => 12, 'min' => 8, 'max' => 32, 'step' => 1, 'unit' => 'bytes'],
'security.remember_validator_bytes' => ['group' => 'security', 'label' => 'Remember validator 바이트', 'type' => 'number', 'default' => 32, 'min' => 16, 'max' => 64, 'step' => 1, 'unit' => 'bytes'],
'security.remember_bind_user_agent' => ['group' => 'security', 'label' => 'Remember User-Agent 고정', 'type' => 'boolean', 'default' => 1],
'security.remember_bind_ip_prefix_v4' => ['group' => 'security', 'label' => 'Remember IPv4 접두사 고정', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 32, 'step' => 1, 'unit' => 'bit'],
'security.session_regenerate_auto_login' => ['group' => 'security', 'label' => '자동 로그인 시 세션 재발급', 'type' => 'boolean', 'default' => 1],
'security.prune_expired_remember_tokens' => ['group' => 'security', 'label' => '만료 Remember 토큰 자동 정리', 'type' => 'boolean', 'default' => 1],
'security.login_csrf_required' => ['group' => 'security', 'label' => '로그인 CSRF 검사', 'type' => 'boolean', 'default' => 1],
'security.login_max_failures' => ['group' => 'security', 'label' => '로그인 실패 허용 횟수', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '회'],
'security.login_window_seconds' => ['group' => 'security', 'label' => '로그인 실패 집계 시간', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 86400, 'step' => 60, 'unit' => '초'],
'security.login_lock_seconds' => ['group' => 'security', 'label' => '로그인 잠금 시간', 'type' => 'number', 'default' => 300, 'min' => 10, 'max' => 86400, 'step' => 10, 'unit' => '초'],
'security.reboot_phrase' => ['group' => 'security', 'label' => '재부팅 확인 문구', 'type' => 'text', 'default' => '재부팅', 'min_length' => 2, 'max_length' => 32],
'security.reboot_timeout_seconds' => ['group' => 'security', 'label' => '재부팅 명령 timeout', 'type' => 'number', 'default' => 5, 'min' => 2, 'max' => 30, 'step' => 1, 'unit' => '초'],
'security.wifi_command_timeout_seconds' => ['group' => 'security', 'label' => 'WiFi 명령 timeout', 'type' => 'number', 'default' => 25, 'min' => 5, 'max' => 90, 'step' => 1, 'unit' => '초'],
'security.shell_default_timeout_seconds' => ['group' => 'security', 'label' => '기본 명령 timeout', 'type' => 'number', 'default' => 8, 'min' => 2, 'max' => 60, 'step' => 1, 'unit' => '초'],
'security.allow_wifi_restart' => ['group' => 'security', 'label' => 'WiFi restart 허용', 'type' => 'boolean', 'default' => 1],
'security.allow_wifi_reload' => ['group' => 'security', 'label' => 'WiFi reload 허용', 'type' => 'boolean', 'default' => 1],
'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'],
'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'],
'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'],
'fan.ramp_up_step' => ['group' => 'fan', 'label' => '자동 상승 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'],
'fan.ramp_down_step' => ['group' => 'fan', 'label' => '자동 하강 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'],
'event.recent_window_seconds' => ['group' => 'event', 'label' => '저전압/스로틀링 최근 판정창', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 7200, 'step' => 30, 'unit' => '초'],
'event.recovery_seconds' => ['group' => 'event', 'label' => '복구 중 유지 시간', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'],
'event.repeated_count' => ['group' => 'event', 'label' => '반복 발생 기준 횟수', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 20, 'step' => 1, 'unit' => '회'],
'notice.baseline_samples' => ['group' => 'notice', 'label' => '팬 이상 기준 표본 수', 'type' => 'number', 'default' => 180, 'min' => 30, 'max' => 600, 'step' => 10, 'unit' => '개'],
'notice.baseline_skip_recent' => ['group' => 'notice', 'label' => '기준 계산 제외 최신 표본', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 30, 'step' => 1, 'unit' => '개'],
'notice.rpm_delta_threshold' => ['group' => 'notice', 'label' => '팬 RPM 이상 감지 차이', 'type' => 'number', 'default' => 1000, 'min' => 100, 'max' => 5000, 'step' => 50, 'unit' => 'RPM'],
'notice.temp_delta_threshold' => ['group' => 'notice', 'label' => '온도 이상 감지 차이', 'type' => 'number', 'default' => 3.0, 'min' => 0.5, 'max' => 15, 'step' => 0.1, 'unit' => 'C'],
'notice.recover_rpm_delta' => ['group' => 'notice', 'label' => '팬 RPM 복구 차이', 'type' => 'number', 'default' => 500, 'min' => 50, 'max' => 3000, 'step' => 50, 'unit' => 'RPM'],
'notice.recover_temp_delta' => ['group' => 'notice', 'label' => '온도 복구 차이', 'type' => 'number', 'default' => 1.5, 'min' => 0.2, 'max' => 10, 'step' => 0.1, 'unit' => 'C'],
'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'],
'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'],
'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
'battery.load_adjust_regression' => ['group' => 'battery', 'label' => '강건 회귀 부하 보정 강도', 'type' => 'number', 'default' => 0.38, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
'battery.load_adjust_voltage' => ['group' => 'battery', 'label' => '전압 후보 부하 보정 강도', 'type' => 'number', 'default' => 0.28, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
'battery.voltage_floor_high' => ['group' => 'battery', 'label' => '전압 하한 높음', 'type' => 'number', 'default' => 3.28, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'],
'battery.voltage_floor_mid' => ['group' => 'battery', 'label' => '전압 하한 중간', 'type' => 'number', 'default' => 3.18, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'],
'battery.voltage_floor_low' => ['group' => 'battery', 'label' => '전압 하한 낮음', 'type' => 'number', 'default' => 3.05, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'],
'battery.candidate_min_ratio' => ['group' => 'battery', 'label' => '후보 최소 속도 비율', 'type' => 'number', 'default' => 0.38, 'min' => 0.05, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
'battery.candidate_max_ratio' => ['group' => 'battery', 'label' => '후보 최대 속도 비율', 'type' => 'number', 'default' => 2.65, 'min' => 1, 'max' => 8, 'step' => 0.05, 'unit' => 'x'],
'battery.capacity_model_weight' => ['group' => 'battery', 'label' => '용량 모델 가중치', 'type' => 'number', 'default' => 0.18, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'],
];
}
function setting_rows(bool $reload = false): array
{
static $cache = null;
if (!$reload && $cache !== null) {
return $cache;
}
try {
$rows = db()->query("SELECT setting_key, setting_value FROM app_settings")->fetchAll();
} catch (Throwable) {
$cache = [];
return $cache;
}
$cache = [];
foreach ($rows as $row) {
$cache[(string)$row['setting_key']] = (string)$row['setting_value'];
}
return $cache;
}
function setting_value(string $key): int|float|string|bool|null
{
$defs = setting_definitions();
if (!isset($defs[$key])) {
return null;
}
$def = $defs[$key];
$raw = setting_rows()[$key] ?? $def['default'];
$type = (string)($def['type'] ?? '');
if ($type === 'number') {
$value = is_numeric($raw) ? (float)$raw : (float)$def['default'];
$value = clamp_float($value, (float)$def['min'], (float)$def['max']);
return fmod((float)$def['step'], 1.0) === 0.0 ? (int)round($value) : $value;
}
if ($type === 'boolean') {
return in_array((string)$raw, ['1', 'true', 'yes', 'on'], true);
}
if ($type === 'select') {
$options = is_array($def['options'] ?? null) ? $def['options'] : [];
return array_key_exists((string)$raw, $options) ? (string)$raw : (string)$def['default'];
}
if ($type === 'text') {
$value = trim((string)$raw);
$min = (int)($def['min_length'] ?? 0);
$max = (int)($def['max_length'] ?? 255);
if (mb_strlen($value) < $min) {
return (string)$def['default'];
}
return mb_substr($value, 0, $max);
}
return $raw;
}
function settings_payload(): array
{
$values = setting_rows();
$groups = [
'security' => '보안 정책',
'fan' => '팬 자동 제어',
'battery' => '배터리 예측',
'event' => '저전압/스로틀링',
'notice' => '팬 이상 감지',
];
$items = [];
foreach (setting_definitions() as $key => $def) {
$items[] = array_merge($def, [
'key' => $key,
'value' => setting_value($key),
'default' => $def['default'],
'custom' => array_key_exists($key, $values),
]);
}
return [
'groups' => $groups,
'items' => $items,
];
}
function save_settings(array $input): void
{
$defs = setting_definitions();
$stmt = db()->prepare("
INSERT INTO app_settings (setting_key, setting_value)
VALUES (:setting_key, :setting_value)
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at = CURRENT_TIMESTAMP
");
foreach ($input as $key => $value) {
if (!isset($defs[$key])) {
continue;
}
$def = $defs[$key];
$type = (string)($def['type'] ?? '');
if ($type === 'number') {
if (!is_numeric($value)) {
continue;
}
$value = clamp_float((float)$value, (float)$def['min'], (float)$def['max']);
} elseif ($type === 'boolean') {
$value = in_array((string)$value, ['1', 'true', 'yes', 'on'], true) ? '1' : '0';
} elseif ($type === 'select') {
$options = is_array($def['options'] ?? null) ? $def['options'] : [];
if (!array_key_exists((string)$value, $options)) {
continue;
}
} elseif ($type === 'text') {
$value = trim((string)$value);
$min = (int)($def['min_length'] ?? 0);
$max = (int)($def['max_length'] ?? 255);
if (mb_strlen($value) < $min) {
continue;
}
$value = mb_substr($value, 0, $max);
}
$stmt->execute([
':setting_key' => $key,
':setting_value' => (string)$value,
]);
}
setting_rows(true);
}
function reset_settings(): void
{
$keys = array_keys(setting_definitions());
if ($keys === []) {
return;
}
$placeholders = implode(',', array_fill(0, count($keys), '?'));
db()->prepare("DELETE FROM app_settings WHERE setting_key IN ($placeholders)")->execute($keys);
setting_rows(true);
}
function ip_prefix_match(string $left, string $right, int $bits): bool
{
if ($bits <= 0) {
return true;
}
$leftLong = ip2long($left);
$rightLong = ip2long($right);
if ($leftLong === false || $rightLong === false) {
return false;
}
$mask = $bits >= 32 ? 0xffffffff : (~((1 << (32 - $bits)) - 1) & 0xffffffff);
return (((int)$leftLong & $mask) === ((int)$rightLong & $mask));
}
function bootstrap_db(): void
{
static $done = false;
@@ -884,21 +1101,34 @@ function require_login(): void
function remember_cookie_options(int $expires): array
{
$secureMode = (string)setting_value('security.cookie_secure_mode');
$secure = match ($secureMode) {
'always' => true,
'never' => false,
default => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
};
$sameSite = (string)setting_value('security.cookie_samesite');
if ($sameSite === 'None') {
$secure = true;
}
return [
'expires' => $expires,
'path' => '/',
'domain' => '',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
'samesite' => $sameSite,
];
}
function issue_remember_token(): void
{
$selector = bin2hex(random_bytes(12));
$validator = bin2hex(random_bytes(32));
$expires = time() + REMEMBER_DAYS * 86400;
$selectorBytes = (int)setting_value('security.remember_selector_bytes');
$validatorBytes = (int)setting_value('security.remember_validator_bytes');
$selector = bin2hex(random_bytes($selectorBytes));
$validator = bin2hex(random_bytes($validatorBytes));
$expires = time() + ((int)setting_value('security.remember_days') * 86400);
$stmt = db()->prepare("
INSERT INTO remember_tokens
@@ -933,10 +1163,12 @@ function issue_remember_token(): void
remember_cookie_options($expires)
);
db()->exec("
DELETE FROM remember_tokens
WHERE expires_at < NOW()
");
if ((bool)setting_value('security.prune_expired_remember_tokens')) {
db()->exec("
DELETE FROM remember_tokens
WHERE expires_at < NOW()
");
}
}
function clear_remember_token(): void
@@ -944,7 +1176,7 @@ function clear_remember_token(): void
$raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
$parts = explode(':', $raw, 2);
if (count($parts) === 2 && preg_match('/^[a-f0-9]{24}$/', $parts[0])) {
if (count($parts) === 2 && preg_match('/^[a-f0-9]{16,64}$/', $parts[0])) {
$stmt = db()->prepare("
DELETE FROM remember_tokens
WHERE selector = :selector
@@ -976,8 +1208,8 @@ function auto_login_from_cookie(): bool
if (
count($parts) !== 2
|| !preg_match('/^[a-f0-9]{24}$/', $parts[0])
|| !preg_match('/^[a-f0-9]{64}$/', $parts[1])
|| !preg_match('/^[a-f0-9]{16,64}$/', $parts[0])
|| !preg_match('/^[a-f0-9]{32,128}$/', $parts[1])
) {
clear_remember_token();
return false;
@@ -989,7 +1221,9 @@ function auto_login_from_cookie(): bool
SELECT
id,
validator_hash,
UNIX_TIMESTAMP(expires_at) AS expires_ts
UNIX_TIMESTAMP(expires_at) AS expires_ts,
last_ip,
user_agent
FROM remember_tokens
WHERE selector = :selector
LIMIT 1
@@ -1011,7 +1245,23 @@ function auto_login_from_cookie(): bool
return false;
}
$currentUserAgent = mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
if ((bool)setting_value('security.remember_bind_user_agent') && (string)($row['user_agent'] ?? '') !== $currentUserAgent) {
clear_remember_token();
return false;
}
$ipPrefix = (int)setting_value('security.remember_bind_ip_prefix_v4');
$currentIp = (string)($_SERVER['REMOTE_ADDR'] ?? 'cli');
if ($ipPrefix > 0 && !ip_prefix_match((string)($row['last_ip'] ?? ''), $currentIp, $ipPrefix)) {
clear_remember_token();
return false;
}
$_SESSION['control_login'] = true;
if ((bool)setting_value('security.session_regenerate_auto_login') && PHP_SAPI !== 'cli') {
session_regenerate_id(true);
}
$stmt = db()->prepare("
UPDATE remember_tokens
@@ -1034,7 +1284,7 @@ function auto_login_from_cookie(): bool
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(24));
$_SESSION['csrf'] = bin2hex(random_bytes((int)setting_value('security.csrf_bytes')));
}
return (string)$_SESSION['csrf'];
@@ -1056,6 +1306,10 @@ function require_csrf(): void
function sh(array $cmd, bool $root = false, int $timeout = 8): array
{
if ($timeout === 8) {
$timeout = (int)setting_value('security.shell_default_timeout_seconds');
}
$full = $cmd;
if (