Files
Gnuboard7/public/install/api/state-management.php
T
HeuJung 9be8faaea2 fix(installer): 설치 상태 파일에 관리자 비밀번호가 평문으로 잔존하는 문제
비밀을 state.json(0664, 실패 시 무기한 잔존)에 기록하지 않고
runtime.php(0600, finalize 시 삭제)로 채널을 일원화한다.

정상 완료 경로에서는 finalize 가 state.json 을 삭제하지만, finalize
미호출 / unlink 실패 / 설치 중단 시에는 평문이 영구히 남았고, finalize
가드가 재호출을 410 차단해 자가 회복도 불가능했다. 삭제라는 단일 경로에
의존해 평문을 보호하던 구조 자체를 제거한다.

SSE 워커는 세션에 접근할 수 없어 파일 경유가 불가피한데, 그 파일이 0664 의
state.json 이었던 것이 구조적 원인이다. 세션을 보유한 유일한 공통 시작점인
install-process.php 를 비밀 이송 지점으로 삼고, db_seed 가 소비한 즉시
제거한다. 레거시 / 실패 경로는 완료·중단·초기화·finalize 시점 redact 로 방어.

동반 수정:
- 세션 config 재병합으로 무력화되던 기존 DB 비밀번호 마스킹 복구
- db_seed 후 putenv 미해제로 모든 exec 자식에 평문이 ENV 상속되던 누출 차단
- runtime read 비밀번호 폴백이 write 값으로 잘못 대체되던 문제 ( 인접)

이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
2026-07-13 17:00:59 +09:00

406 lines
16 KiB
PHP

<?php
/**
* G7 인스톨러 - 설치 상태 관리 통합 API
*
* 설치 상태 조회, 초기화, 중단 기능을 하나의 API로 통합
*
* 엔드포인트:
* - GET ?action=get : 현재 설치 상태 조회 (action 생략 시 기본값)
* - POST ?action=reset : 설치 상태 초기화 (Step 3로 이동)
* - POST ?action=abort : 설치 중단 (현재 작업만 롤백)
*/
/**
* 설치 상태 관리 API 클래스
*/
class StateManagementApi
{
/**
* 요청 처리 메인 메서드
*/
public function handleRequest(): void
{
// JSON 헤더 설정
$this->setJsonHeaders();
// HTTP 메서드 및 action 파라미터 확인
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? ($method === 'GET' ? 'get' : null);
try {
// action에 따라 적절한 메서드 호출
match ($action) {
'get' => $this->getState(),
'reset' => $this->resetState(),
'abort' => $this->abortInstallation(),
default => $this->error400('Invalid action parameter'),
};
} catch (Exception $e) {
$this->error500($e);
}
}
/**
* JSON 응답 헤더 설정
*/
private function setJsonHeaders(): void
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
}
/**
* GET ?action=get
* 현재 설치 상태 조회
*
* JavaScript 폴링(1초 간격)으로 호출되어 실시간 진행 상황을 업데이트합니다.
*/
private function getState(): void
{
// GET 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode([
'error' => 'Method Not Allowed',
'message' => lang('error_get_method_required'),
], JSON_UNESCAPED_UNICODE);
exit;
}
// state.json 파일 실제 존재 여부 (getInstallationState는 부재 시 DEFAULT_INSTALLATION_STATE를 반환하므로
// isset($state['installation_status']) 로는 "기본 상태"와 "실제 파일 상태"를 구분할 수 없다)
$stateFileExists = file_exists(STATE_PATH);
$installedFlagPath = BASE_PATH.'/storage/app/g7_installed';
// 현재 설치 상태 조회
$state = getInstallationState();
// installation_status 값을 status로 매핑
$status = 'pending';
if (! $stateFileExists && file_exists($installedFlagPath)) {
// state.json 삭제 + g7_installed 플래그 존재 → 설치 완료 후 정리된 상태
// 폴링이 완료 시점을 놓치면 이후 주기에서 이 분기로 completed 전환
$status = 'completed';
} elseif ($stateFileExists && isset($state['installation_status'])) {
switch ($state['installation_status']) {
case 'not_started':
case 'ready': // ready도 pending으로 매핑 (설치 대기 상태)
$status = 'pending';
break;
case 'running':
$status = 'running';
break;
case 'completed':
$status = 'completed';
break;
case 'failed':
$status = 'failed';
break;
case 'aborted':
$status = 'aborted';
break;
default:
$status = $state['installation_status'];
}
}
// 폴링 모드 증분 조회 지원 (?log_offset=N)
$logOffset = isset($_GET['log_offset']) ? max(0, (int) $_GET['log_offset']) : 0;
// 로그 파일에서 로그 읽기 (offset 이후만)
$logs = getInstallationLogs($logOffset);
$logTotal = getInstallationLogCount();
// API 응답 형식으로 변환
$response = [
'status' => $status,
'current_step' => $state['current_step'] ?? 0,
'current_task' => $state['current_task'] ?? null,
'completed_tasks' => $state['completed_tasks'] ?? [],
'logs' => $logs,
'log_total' => $logTotal,
'error' => $state['error'] ?? null,
'last_updated' => $state['last_updated'] ?? null,
// 실패 정보 (새로고침 후에도 표시용)
'failed_task' => $state['failed_task'] ?? null,
'error_message_key' => $state['error_message_key'] ?? null,
'error_detail' => $state['error_detail'] ?? null,
'rollback_failure' => $state['rollback_failure'] ?? null,
'manual_commands' => $state['manual_commands'] ?? null,
// SSE/폴링 듀얼 모드 지원
'installation_mode' => $state['installation_mode'] ?? 'sse',
];
// JSON 응답 반환
//
// 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가
// 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어
// 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다.
// 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이
// 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다.
$encoded = json_encode(
$response,
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
);
if ($encoded === false) {
// substitute 플래그로도 인코딩 실패한 극단적 케이스 — 폴링이 파싱 가능한
// 최소 유효 JSON 을 반환해 프론트가 다음 tick 에서 정상 복구되도록 한다.
$encoded = json_encode([
'status' => $status,
'logs' => [],
'log_total' => $logTotal,
], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
echo $encoded;
}
/**
* POST ?action=reset
* 설치 상태 초기화 및 적절한 Step으로 이동
*
* 설치 상태를 초기화하여 설정 페이지로 돌아갈 수 있도록 합니다.
* DB 롤백은 수행하지 않습니다 (사용자가 수동으로 DB 정리 필요).
* (마이그레이션 실패 시 자동 롤백은 install-worker.php에서 처리)
*/
private function resetState(): void
{
// POST 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode([
'success' => false,
'message' => 'Only POST method is allowed',
], JSON_UNESCAPED_UNICODE);
exit;
}
// 현재 상태 조회
$state = getInstallationState();
// 항상 Step 4 (확장 선택)로 이동
$targetStep = 4;
addLog(lang('state_reset_requested'));
addLog(lang('state_reset_db_notice'));
// 상태 초기화 (DB 롤백 없이)
$state['current_step'] = $targetStep;
$_SESSION['installer_current_step'] = $targetStep;
$state['step_status'][$targetStep] = 'pending';
$state['installation_status'] = 'not_started';
$state['completed_tasks'] = [];
$state['current_task'] = null;
$state['error'] = null;
$state['failed_task'] = null;
$state['failed_task_target'] = null;
$state['error_message_key'] = null;
$state['error_detail'] = null;
$state['rollback_failure'] = null;
$state['manual_commands'] = null;
$state['last_updated'] = gmdate('Y-m-d\TH:i:s\Z');
// 보존되는 config 에서 비밀 제거 (이슈 #465) — 초기화 후 무기한 잔존 가능하므로
// 평문이 남지 않아야 한다. 비밀번호는 runtime.php 에 있고, 재설치 시 Step 3
// 재입력 또는 runtime 폴백으로 복원된다.
$state = redactInstallationStateSecrets($state);
// 상태 저장
$saved = saveInstallationState($state);
if (! $saved) {
throw new Exception(lang('state_save_failed'));
}
addLog(lang('state_reset_completed', ['step' => $targetStep]));
// 성공 응답
http_response_code(200);
echo json_encode([
'success' => true,
'message' => lang('state_reset_completed', ['step' => $targetStep]),
'target_step' => $targetStep,
], JSON_UNESCAPED_UNICODE);
}
/**
* POST ?action=abort
* 설치 중단 (현재 작업만 롤백)
*
* 사용자가 명시적으로 설치 중단 버튼을 클릭했을 때 호출됩니다.
* 현재 진행 중인 작업만 롤백하고, 완료된 작업은 유지합니다.
*/
private function abortInstallation(): void
{
// POST 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode([
'success' => false,
'message' => lang('api_method_not_allowed'),
]);
exit;
}
addLog(lang('abort_api_requested'));
// 설치 상태 가져오기
$state = getInstallationState();
// 디버그: 현재 state 상태 로깅
addLog(lang('abort_api_current_status', ['status' => $state['installation_status'] ?? 'null']));
addLog(lang('abort_api_current_task', ['task' => $state['current_task'] ?? 'null']));
addLog(lang('abort_api_completed_count', ['count' => count($state['completed_tasks'] ?? [])]));
// 설치가 이미 완료된 경우
if (isset($state['installation_status']) && $state['installation_status'] === 'completed') {
addLog(lang('abort_api_already_completed'));
echo json_encode([
'success' => false,
'message' => lang('abort_api_already_completed'),
]);
exit;
}
// 이미 중단된 경우 - 멱등성 보장 (재진입 시 400 에러 방지)
if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') {
addLog(lang('abort_api_already_aborted'));
echo json_encode([
'success' => true,
'message' => lang('abort_api_already_aborted'),
]);
exit;
}
// 설치가 진행 중이 아닌 경우
if (! isset($state['installation_status']) || $state['installation_status'] !== 'running') {
addLog(lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null']));
echo json_encode([
'success' => false,
'message' => lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null']),
]);
exit;
}
addLog(lang('abort_user_requested'));
// 현재 진행 중인 작업만 롤백 (완료된 작업은 유지)
// 결과는 rollbackCurrentTask()에서 로깅됨
$rollbackResult = rollbackCurrentTask($state);
// 설치 상태를 'aborted'로 변경
addLog(lang('abort_api_status_change'));
$state['installation_status'] = 'aborted';
// current_step은 5를 유지 (중단된 화면을 보여주기 위해 - Step 5 = Installation)
$state['current_task'] = null;
// completed_tasks는 유지 (이미 완료된 작업은 롤백하지 않음)
$state['aborted_at'] = date('Y-m-d H:i:s');
$state['abort_reason'] = 'User requested';
// 롤백 실패 정보 저장 (새로고침 후에도 표시용)
if (isset($rollbackResult['success']) && ! $rollbackResult['success']) {
$state['rollback_failure'] = [
'task' => $rollbackResult['task'] ?? null,
'message' => $rollbackResult['message'] ?? null,
'message_key' => 'failed_rollback_manual_cleanup',
'detail_key' => 'failed_rollback_manual_cleanup_detail',
];
}
// 세션도 Step 5를 유지 (중단 화면 표시)
// 중단 상태는 무기한 잔존 가능 — 보존되는 config 에서 비밀 제거 (이슈 #465)
$state = redactInstallationStateSecrets($state);
$saveResult = saveInstallationState($state);
addLog(lang('abort_api_save_result', ['result' => $saveResult ? 'success' : 'failed']));
// 저장 후 실제 state.json 확인
$verifyState = getInstallationState();
addLog(lang('abort_api_verify_status', ['status' => $verifyState['installation_status'] ?? 'null']));
addLog(lang('abort_installation_stopped'));
// 성공 응답 (리다이렉트 없음 - 현재 Step 5 유지)
echo json_encode([
'success' => true,
'message' => lang('abort_installation_stopped'),
]);
}
/**
* 400 Bad Request 응답
*/
private function error400(string $message): void
{
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Bad Request',
'message' => $message,
], JSON_UNESCAPED_UNICODE);
exit;
}
/**
* 500 Internal Server Error 응답
*/
private function error500(Exception $e): void
{
// 에러 로깅
if (function_exists('logInstallationError')) {
logInstallationError(lang('error_state_management'), $e);
} elseif (function_exists('addLog')) {
addLog(lang('error_log_prefix', ['error' => $e->getMessage()]));
}
// 에러 응답
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Internal Server Error',
'message' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE);
exit;
}
}
// ========================================
// 실행 부분
// ========================================
// 필수 파일 로드 (config.php가 BASE_PATH를 정의함)
require_once __DIR__.'/../includes/config.php';
require_once __DIR__.'/../includes/session.php';
require_once __DIR__.'/../includes/functions.php';
require_once __DIR__.'/../includes/installer-state.php';
// 롤백 함수 로드 (reset, abort 액션에서 필요)
require_once __DIR__.'/rollback-functions.php';
// 설치 완료 후 진입 차단 (KVE-2026-1056)
// finalize 전용 가드 — `.env` 의 INSTALLER_COMPLETED=true 단독으로만 차단한다.
// 일반 가드(installer_guard_or_410)는 `g7_installed` 락 파일도 차단 사유로 삼는데,
// 그 락 파일은 마지막 task `complete_flag` 가 먼저 생성한다. 그 직후에도 본 엔드포인트의
// action=get 폴링(1초 간격)이 계속되어 `completed` 상태를 받아야 완료 화면으로 전환되므로,
// 락 파일 기준으로 차단하면 정상 설치가 "진행 중"에 고착되는 자가 차단 회귀가 발생한다.
// 완전 완료 신호인 `.env` 플래그를 기준으로 삼아 get/reset/abort 를
// 일괄 차단하면서 폴링 구간은 비파괴로 통과시킨다.
require_once __DIR__.'/_guard.php';
installer_guard_finalize_or_410();
// 다국어 로드
$currentLang = getCurrentLanguage();
$translations = loadTranslations($currentLang);
// API 인스턴스 생성 및 요청 처리
$api = new StateManagementApi;
$api->handleRequest();