비밀을 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 업그레이드 스텝이 잔존 파일을 정리한다. 완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
353 lines
14 KiB
PHP
353 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* 인스톨러 런타임 설정 헬퍼
|
|
*
|
|
* 설치 진행 중 동적 설정(DB 자격증명, APP_KEY) 을 .env 가 아닌
|
|
* storage/installer/runtime.php 에 PHP 배열로 기록한다.
|
|
*
|
|
* Laravel 부팅 시 InstallerRuntimeServiceProvider 가 이 파일을 읽어
|
|
* config('database.*') / config('app.key') 에 주입하므로,
|
|
* 설치 진행 중 .env 를 건드리지 않고도 마이그레이션/시더가 동작한다.
|
|
*
|
|
* 설치 완료 UI 노출 후 finalize-env.php 가 호출되면 이 파일의 내용을
|
|
* .env 에 머지하고 파일을 삭제한다.
|
|
*
|
|
* @see https://github.com/gnuboard/g7/issues/23
|
|
*/
|
|
if (! defined('BASE_PATH')) {
|
|
throw new RuntimeException('installer-runtime.php requires BASE_PATH constant.');
|
|
}
|
|
|
|
if (! defined('INSTALLER_RUNTIME_PATH')) {
|
|
define('INSTALLER_RUNTIME_PATH', BASE_PATH.'/storage/installer/runtime.php');
|
|
}
|
|
|
|
if (! function_exists('readInstallerRuntime')) {
|
|
/**
|
|
* runtime.php 를 읽어 배열로 반환한다.
|
|
*
|
|
* @return array<string, mixed>|null 파일이 없거나 형식이 잘못된 경우 null
|
|
*/
|
|
function readInstallerRuntime(): ?array
|
|
{
|
|
if (! is_file(INSTALLER_RUNTIME_PATH)) {
|
|
return null;
|
|
}
|
|
|
|
$data = @include INSTALLER_RUNTIME_PATH;
|
|
|
|
return is_array($data) ? $data : null;
|
|
}
|
|
}
|
|
|
|
if (! function_exists('writeInstallerRuntime')) {
|
|
/**
|
|
* runtime.php 를 atomic 하게 작성한다.
|
|
*
|
|
* 디렉토리 부재 시 생성. 파일 권한은 0600 으로 설정.
|
|
*
|
|
* @param array<string, mixed> $data 저장할 배열
|
|
* @return bool 성공 여부
|
|
*/
|
|
function writeInstallerRuntime(array $data): bool
|
|
{
|
|
$dir = dirname(INSTALLER_RUNTIME_PATH);
|
|
|
|
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
|
return false;
|
|
}
|
|
|
|
$php = "<?php\n\nreturn ".var_export($data, true).";\n";
|
|
|
|
$tmp = INSTALLER_RUNTIME_PATH.'.tmp';
|
|
if (@file_put_contents($tmp, $php, LOCK_EX) === false) {
|
|
return false;
|
|
}
|
|
|
|
// atomic rename
|
|
if (! @rename($tmp, INSTALLER_RUNTIME_PATH)) {
|
|
@unlink($tmp);
|
|
|
|
return false;
|
|
}
|
|
|
|
@chmod(INSTALLER_RUNTIME_PATH, 0600);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (! function_exists('deleteInstallerRuntime')) {
|
|
/**
|
|
* runtime.php 를 삭제한다.
|
|
*
|
|
* @return bool 삭제 성공 또는 이미 부재 시 true
|
|
*/
|
|
function deleteInstallerRuntime(): bool
|
|
{
|
|
if (! is_file(INSTALLER_RUNTIME_PATH)) {
|
|
return true;
|
|
}
|
|
|
|
return @unlink(INSTALLER_RUNTIME_PATH);
|
|
}
|
|
}
|
|
|
|
if (! function_exists('generateAppKeyInline')) {
|
|
/**
|
|
* APP_KEY 문자열을 pure PHP 로 생성한다.
|
|
*
|
|
* Laravel 의 Encrypter::generateKey('AES-256-CBC') 와 동일한 32 byte 키를
|
|
* 'base64:' prefix 와 함께 반환한다. artisan key:generate 가 .env 를 직접
|
|
* 수정하므로 인스톨러는 본 함수를 사용해 .env 를 건드리지 않고 키를 확보.
|
|
*
|
|
* @return string Laravel 표준 형식 'base64:...'
|
|
*/
|
|
function generateAppKeyInline(): string
|
|
{
|
|
return 'base64:'.base64_encode(random_bytes(32));
|
|
}
|
|
}
|
|
|
|
if (! function_exists('mergeRuntimeIntoEnv')) {
|
|
/**
|
|
* runtime 배열을 .env 문자열에 머지하여 반환한다.
|
|
*
|
|
* .env.example 템플릿 기반 콘텐츠(generateEnvContent() 결과) 에 runtime 의
|
|
* DB 자격증명 + APP_KEY 를 치환하고 INSTALLER_COMPLETED=true 라인을 추가한다.
|
|
* 파일 IO 를 수행하지 않으므로 단위 테스트 가능.
|
|
*
|
|
* 안전망: generateEnvContent() 가 state.config 에서 DB 정보를 못 읽었을 때
|
|
* (예: state.json 결손) 에도 runtime 의 DB 정보로 .env 이 완성되도록 동작.
|
|
*
|
|
* @param string $envContent .env.example 기반 치환된 본문
|
|
* @param array<string, mixed> $runtime runtime.php 배열
|
|
* @return string 최종 .env 콘텐츠
|
|
*/
|
|
function mergeRuntimeIntoEnv(string $envContent, array $runtime): string
|
|
{
|
|
// DB 자격증명 치환 — state.config 결손 안전망
|
|
$write = $runtime['db']['write'] ?? null;
|
|
if (is_array($write)) {
|
|
$envContent = replaceEnvLine($envContent, 'DB_WRITE_HOST', (string) ($write['host'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PORT', (string) ($write['port'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_WRITE_DATABASE', (string) ($write['database'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_WRITE_USERNAME', (string) ($write['username'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', escapeEnvValue((string) ($write['password'] ?? '')));
|
|
}
|
|
|
|
// Read DB — runtime 에 read 키가 있을 때(use_read_db=true)만 명시 기록한다.
|
|
// 미지정 시 DB_READ_* 를 빈 값으로 남겨 config/database.php 의 write fallback(Elvis)
|
|
// 에 위임 → .env 에 write 값이 중복 기록되지 않고, write 변경 시 read stale 위험 제거.
|
|
// (이슈 #63)
|
|
$read = $runtime['db']['read'] ?? null;
|
|
if (is_array($read)) {
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? ''));
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', escapeEnvValue((string) ($read['password'] ?? '')));
|
|
} else {
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', '');
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', '');
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', '');
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', '');
|
|
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', '');
|
|
}
|
|
|
|
if (isset($runtime['db']['prefix'])) {
|
|
$envContent = replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']);
|
|
}
|
|
|
|
// APP_KEY 치환
|
|
$appKey = $runtime['app']['key'] ?? null;
|
|
if (is_string($appKey) && str_starts_with($appKey, 'base64:')) {
|
|
$envContent = replaceEnvLine($envContent, 'APP_KEY', $appKey);
|
|
}
|
|
|
|
// INSTALLER_COMPLETED 플래그 추가 (CachesModuleStatus 등이 사용)
|
|
if (! preg_match('/^INSTALLER_COMPLETED=/m', $envContent)) {
|
|
$envContent = rtrim($envContent)."\n\n# Installation Status\nINSTALLER_COMPLETED=true\n";
|
|
}
|
|
|
|
return $envContent;
|
|
}
|
|
}
|
|
|
|
if (! function_exists('escapeEnvValue')) {
|
|
/**
|
|
* .env 값 이스케이프 (functions.php 의 동일 함수 polyfill).
|
|
*
|
|
* 인스톨러 본 흐름은 functions.php 가 먼저 로드되므로 if 분기로 진입하지
|
|
* 않으나, 단위 테스트에서 functions.php 없이 installer-runtime.php 만
|
|
* 로드하는 경우를 위한 안전망.
|
|
*
|
|
* @param string $value 이스케이프할 값
|
|
* @return string 큰따옴표로 감싸고 내부 큰따옴표/백슬래시를 이스케이프한 값
|
|
*/
|
|
function escapeEnvValue(string $value): string
|
|
{
|
|
// CR/LF 제거 — .env 라인 주입 차단 (functions.php 의 정의와 동일 정책)
|
|
if ($value !== '') {
|
|
$value = str_replace(["\r", "\n"], '', $value);
|
|
}
|
|
|
|
if ($value === '') {
|
|
return '""';
|
|
}
|
|
|
|
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
|
|
|
|
return '"'.$escaped.'"';
|
|
}
|
|
}
|
|
|
|
if (! function_exists('replaceEnvLine')) {
|
|
/**
|
|
* .env 문자열에서 특정 키의 라인을 치환한다.
|
|
*
|
|
* 라인이 없으면 끝에 추가. 값에 공백/특수문자가 있는 경우 호출자가
|
|
* escapeEnvValue() 등으로 escape 후 전달.
|
|
*
|
|
* @param string $envContent .env 본문
|
|
* @param string $key 환경 변수 키
|
|
* @param string $value 대입할 값 (escape 처리된 상태)
|
|
* @return string 치환된 .env 본문
|
|
*/
|
|
function replaceEnvLine(string $envContent, string $key, string $value): string
|
|
{
|
|
$line = $key.'='.$value;
|
|
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
|
|
|
|
$replaced = preg_replace($pattern, $line, $envContent, 1, $count);
|
|
|
|
if ($count === 0) {
|
|
return rtrim($envContent)."\n".$line."\n";
|
|
}
|
|
|
|
return $replaced;
|
|
}
|
|
}
|
|
|
|
if (! function_exists('buildInstallerRuntimeFromState')) {
|
|
/**
|
|
* 설치 상태(state.json 의 config) 에서 runtime.php 용 배열을 생성한다.
|
|
*
|
|
* runtime.php 가 이미 존재하고 APP_KEY 가 있으면 재사용 (재시도 시 키 보존).
|
|
* 없으면 generateAppKeyInline() 으로 새 키 생성.
|
|
*
|
|
* @param array<string, mixed> $stateConfig state.json 의 config 섹션
|
|
* @return array<string, mixed> runtime.php 형식의 배열
|
|
*/
|
|
function buildInstallerRuntimeFromState(array $stateConfig): array
|
|
{
|
|
$existing = readInstallerRuntime();
|
|
$appKey = $existing['app']['key'] ?? null;
|
|
|
|
if (! $appKey || ! str_starts_with($appKey, 'base64:')) {
|
|
$appKey = generateAppKeyInline();
|
|
}
|
|
|
|
// state.config 에는 더 이상 비밀번호가 기록되지 않으므로(이슈 #465), 세션이 유실된
|
|
// 재개/재시도 경로에서는 기존 runtime.php 의 값을 보존해야 한다. app.key 보존과
|
|
// 동일한 패턴.
|
|
$writePassword = (string) ($stateConfig['db_write_password'] ?? ($stateConfig['db_password'] ?? ''));
|
|
if ($writePassword === '') {
|
|
$writePassword = (string) ($existing['db']['write']['password'] ?? '');
|
|
}
|
|
|
|
$runtime = [
|
|
'db' => [
|
|
'write' => [
|
|
'host' => $stateConfig['db_write_host'] ?? ($stateConfig['db_host'] ?? '127.0.0.1'),
|
|
'port' => $stateConfig['db_write_port'] ?? ($stateConfig['db_port'] ?? '3306'),
|
|
'database' => $stateConfig['db_write_database'] ?? ($stateConfig['db_database'] ?? ''),
|
|
'username' => $stateConfig['db_write_username'] ?? ($stateConfig['db_username'] ?? ''),
|
|
'password' => $writePassword,
|
|
],
|
|
'prefix' => $stateConfig['db_prefix'] ?? '',
|
|
],
|
|
'app' => [
|
|
'key' => $appKey,
|
|
],
|
|
'created_at' => date('c'),
|
|
];
|
|
|
|
// admin 비밀번호는 db_seed 가 소비할 때까지 runtime 에 보존한다. 호출자
|
|
// (install-process.php) 가 세션 값으로 덮어쓰는 경우를 제외하면, 재시도/재개 시
|
|
// 기존 runtime 의 값이 유실되지 않아야 한다 (유실 시 db_seed 재실행 불가).
|
|
if (isset($existing['admin']) && is_array($existing['admin'])) {
|
|
$runtime['admin'] = $existing['admin'];
|
|
}
|
|
|
|
// Read 커넥션은 use_read_db 플래그가 켜진 경우에만 포함한다 (판정 SSoT).
|
|
// 플래그가 꺼져 있으면 db_read_host 에 잔존 값이 있어도 무시 → runtime 에 read 키
|
|
// 미생성 → 하류(mergeRuntimeIntoEnv / InstallerRuntimeServiceProvider)가 write 로
|
|
// 자동 동기화. (이슈 #63: use_read_db=false 인데 db_read_host 를 참조하던 회귀 차단)
|
|
if (! empty($stateConfig['use_read_db'])
|
|
&& ! empty($stateConfig['db_read_host'])
|
|
&& $stateConfig['db_read_host'] !== ($stateConfig['db_write_host'] ?? null)) {
|
|
// read 비밀번호 폴백 순서: state.config → 기존 runtime 의 read 비밀번호 →
|
|
// write 비밀번호. 기존 runtime 을 건너뛰고 write 값으로 대체하면 read 전용
|
|
// 계정의 비밀번호가 write 값으로 오염된다 (이슈 #465 부수 수정).
|
|
$readPassword = (string) ($stateConfig['db_read_password'] ?? '');
|
|
if ($readPassword === '') {
|
|
$readPassword = (string) ($existing['db']['read']['password'] ?? '');
|
|
}
|
|
if ($readPassword === '') {
|
|
$readPassword = (string) $runtime['db']['write']['password'];
|
|
}
|
|
|
|
$runtime['db']['read'] = [
|
|
'host' => $stateConfig['db_read_host'],
|
|
'port' => $stateConfig['db_read_port'] ?? $runtime['db']['write']['port'],
|
|
'database' => $stateConfig['db_read_database'] ?? $runtime['db']['write']['database'],
|
|
'username' => $stateConfig['db_read_username'] ?? $runtime['db']['write']['username'],
|
|
'password' => $readPassword,
|
|
];
|
|
}
|
|
|
|
return $runtime;
|
|
}
|
|
}
|
|
|
|
if (! function_exists('hydrateDbSecretsFromRuntime')) {
|
|
/**
|
|
* state.config 에서 제거된 DB 비밀번호를 runtime.php 값으로 채운다 (이슈 #465).
|
|
*
|
|
* state.json 은 더 이상 DB 비밀번호를 보관하지 않으므로, state.config 로 DB 에
|
|
* 접속하던 소비처(db_cleanup / 롤백 seed truncate) 는 이 헬퍼로 자격증명을 복원해야
|
|
* 한다. 두 소비처 모두 env_update 태스크 이후에 실행되므로 runtime.php 는 항상 존재.
|
|
*
|
|
* runtime 부재 또는 config 에 이미 비밀번호가 있으면 원본을 그대로 반환.
|
|
*
|
|
* @param array<string, mixed> $config state.config (비밀번호 결손 가능)
|
|
* @return array<string, mixed> DB 비밀번호가 복원된 config
|
|
*/
|
|
function hydrateDbSecretsFromRuntime(array $config): array
|
|
{
|
|
$runtime = readInstallerRuntime();
|
|
|
|
if ($runtime === null) {
|
|
return $config;
|
|
}
|
|
|
|
if (empty($config['db_write_password'])) {
|
|
$writePassword = $runtime['db']['write']['password'] ?? '';
|
|
if ($writePassword !== '') {
|
|
$config['db_write_password'] = $writePassword;
|
|
}
|
|
}
|
|
|
|
if (empty($config['db_read_password'])) {
|
|
$readPassword = $runtime['db']['read']['password'] ?? '';
|
|
if ($readPassword !== '') {
|
|
$config['db_read_password'] = $readPassword;
|
|
}
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
}
|