From 0e7fa04b03fa3d75e76e2d917e01171c2cae85e0 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Fri, 15 May 2026 19:24:41 +0900 Subject: [PATCH] v7.0.0-beta.7 release --- .env.example | 2 +- .env.testing.example | 2 +- CHANGELOG.md | 24 + INSTALL.md | 177 +++- README.md | 2 +- .../Concerns/BundledExtensionUpdatePrompt.php | 1 + .../Core/ExecuteBundledUpdatesCommand.php | 1 + .../InstallerRuntimeServiceProvider.php | 361 +++++++++ app/Services/CoreUpdateService.php | 70 +- config/app.php | 2 +- .../CHANGELOG.md | 6 + .../frontend/partial/admin.json | 7 +- .../language-pack.json | 2 +- public/install/api/_guard.php | 62 ++ public/install/api/finalize-env.php | 65 +- public/install/includes/installer-state.php | 90 ++- public/install/includes/session.php | 23 +- public/install/lang/en.php | 7 + public/install/lang/ko.php | 7 + public/install/views/5-installation.php | 49 ++ .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 7 + .../lang/partial/en/admin.json | 7 +- .../lang/partial/ko/admin.json | 7 +- .../_modal_core_update_guide.json | 34 +- .../sirsoft-admin_basic/package-lock.json | 4 +- .../_bundled/sirsoft-admin_basic/package.json | 2 +- .../sirsoft-admin_basic/template.json | 2 +- ...a7FinalizeOrphanedInstallerRuntimeTest.php | 545 +++++++++++++ tests/Unit/Extension/UpgradeStepsTest.php | 24 + tests/Unit/Installer/InstallerGuardTest.php | 71 +- .../InstallerSecurityHardeningTest.php | 10 +- .../InstallerRuntimeServiceProviderTest.php | 202 +++++ .../installer-runtime-env-finalize.yaml | 44 + .../installer-session-cookie-probe.yaml | 2 +- upgrades/Upgrade_7_0_0_beta_7.php | 30 + .../01_FinalizeOrphanedInstallerRuntime.php | 757 ++++++++++++++++++ 36 files changed, 2625 insertions(+), 83 deletions(-) create mode 100644 tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php create mode 100644 upgrades/Upgrade_7_0_0_beta_7.php create mode 100644 upgrades/data/7.0.0-beta.7/migrations/01_FinalizeOrphanedInstallerRuntime.php diff --git a/.env.example b/.env.example index 2057cd34..4b1986af 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.0-beta.6 +APP_VERSION=7.0.0-beta.7 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 509844ba..21c692a6 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.0-beta.6 +APP_VERSION=7.0.0-beta.7 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e0e3a4..7a2bb7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.0-beta.7] - 2026-05-15 + +### Fixed + +- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.) +- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다. +- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다. +- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다. +- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다. +- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다. + +### Changed + +- `INSTALL.md` 에 "업그레이드" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.) +- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.) + +### Notes — `.env` 권한 권장안 + +위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요. + +- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다. +- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다. +- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다. + ## [7.0.0-beta.6] - 2026-05-14 ### Fixed diff --git a/INSTALL.md b/INSTALL.md index b67b5651..aa934092 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.0-beta.6 g7 +# (필요 시) mv g7-7.0.0-beta.7 g7 # ZIP 파일 정리 (선택) rm g7-release.zip @@ -338,6 +338,157 @@ http://도메인/install --- +## 업그레이드 + +이미 운영 중인 그누보드7을 새 버전으로 업그레이드할 때의 절차입니다. 신버전 ZIP 또는 git pull 로 파일만 덮어쓰면 **데이터베이스 마이그레이션, 데이터 시드, 확장(모듈/플러그인/템플릿) 재동기화가 함께 수행되지 않아** 게시판/회원/상품 데이터 누락이나 부정합 부팅 실패가 발생합니다. 반드시 아래 절차를 따라주세요. + +### 개요 + +그누보드7 업그레이드의 SSoT(Single Source of Truth)는 Artisan 커맨드입니다. + +```bash +sudo php artisan core:update +``` + +이 커맨드 1회 실행으로 다음 작업이 자동 수행됩니다. + +- 신버전 다운로드 및 압축 해제 (GitHub Release 또는 지정 ZIP) +- 자동 백업 생성 (실패 시 자동 롤백) +- 유지보수 모드 자동 진입 +- 데이터베이스 마이그레이션 실행 +- 업그레이드 스텝 실행 (버전별 데이터 보정) +- 번들 확장 자동 재동기화 +- 캐시 정리 및 유지보수 모드 해제 + +### 업그레이드 사전 준비 + +업그레이드 전 다음을 확인하세요. + +1. **데이터베이스 백업** (필수) + + ```bash + mysqldump -u DB사용자 -p DB이름 > backup-$(date +%Y%m%d).sql + ``` + +2. **활성 디렉토리 백업 권장** — 직접 수정한 코드나 외부 확장(`_bundled` 에 포함되지 않은 GitHub install 확장) 이 있다면 다음 4개 디렉토리를 별도 위치로 백업하세요. + + ```bash + tar -czf extensions-backup-$(date +%Y%m%d).tar.gz modules plugins templates lang-packs + ``` + +3. **SSH/CLI 접근 확보** — 코어 업그레이드는 보안상 웹 UI 에서 실행할 수 없으며, 반드시 서버 터미널(SSH) 에서 직접 실행해야 합니다. + +### 방법 1: 관리자 UI 에서 업데이트 확인 (권장 동선 시작점) + +운영자가 새 버전 출시 여부를 가장 빠르게 알 수 있는 동선입니다. + +1. 관리자 페이지 로그인 → **환경설정 > 정보** 탭 이동 +2. "**업데이트 확인**" 버튼 클릭 → 최신 버전과 변경사항 모달 표시 +3. 신버전이 감지되면 "**변경사항 보기**" 로 changelog 확인 → "**업데이트 방법**" 버튼 클릭 +4. 모달이 권장 명령어와 본 가이드 링크를 안내하므로 그 안내에 따라 **서버 터미널에서 직접 실행** + +> 모달은 안내 전용입니다. 보안상 UI 에서 코어 업그레이드를 직접 실행하지 않으며, 운영자가 권한 일치를 확인한 후 CLI 에서 실행해야 합니다. + +### 방법 2: CLI 자동 업그레이드 (표준 절차) + +대다수 환경에서 권장되는 절차입니다. + +```bash +sudo php artisan core:update +``` + +#### `sudo` 가 권장되는 이유 + +업그레이드는 다음 파일들을 수정·생성하므로, 운영 환경에서 일반 사용자 권한으로 모두 접근 가능한 경우가 드뭅니다. + +- `.env` — `APP_VERSION` 갱신 (보안상 `0600` 또는 `0640` 권한, 소유자는 보통 웹 서버 사용자 `www-data` · `nginx` · `apache` 등) +- `bootstrap/cache/` — 설정·라우트·서비스 캐시 갱신 +- `storage/framework/`, `storage/logs/` — 캐시·로그 파일 생성 +- `modules/`, `plugins/`, `templates/`, `lang-packs/` — 확장 재동기화 + +`sudo` 로 root 권한을 위임받으면 위 파일들의 소유자가 누구든(웹 서버 사용자 또는 별도 배포 사용자) 권한 충돌 없이 한 번에 처리할 수 있습니다. 업그레이드 도중 `.env` 머지 단계는 기존 소유자·그룹을 그대로 보존하므로, root 로 실행해도 파일 권한이 임의로 변경되지 않습니다. + +#### `sudo` 없이 실행해도 되는 환경 + +- 소유자가 명령줄 사용자와 일치하는 **단일 사용자 호스팅** (Cafe24 등 공유 호스팅, 개인 VPS 의 사용자 계정 운영) — 그냥 `php artisan core:update` 실행 +- **Windows (XAMPP/Laragon 등)** — 관리자 권한 PowerShell 에서 `php artisan core:update` 실행 + +> 자신의 환경이 어느 쪽인지 확인하려면 `ls -l .env` 의 소유자(세 번째 컬럼) 가 명령줄 로그인 사용자와 일치하는지 보세요. 일치하면 `sudo` 없이 실행 가능합니다. + +### 방법 3: 수동 ZIP 업그레이드 (PHP zip 확장 미설치 환경) + +PHP `zip` 확장 또는 시스템 `unzip` 명령이 없는 서버에서는 GitHub Release ZIP 을 직접 다운로드한 뒤 압축 해제 경로를 지정해 업그레이드합니다. + +```bash +# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제 +wget https://github.com/gnuboard/g7/releases/download/<버전>/<파일명>.zip +unzip <파일명>.zip -d /tmp/g7-new + +# 2. 압축 해제된 경로를 지정하여 업데이트 실행 +sudo php artisan core:update --source=/tmp/g7-new +``` + +`--source=` 옵션은 다운로드 단계를 건너뛰고 지정 경로의 파일을 신버전으로 간주합니다. + +### 사용자군별 단계적 업그레이드 + +여러 베타 버전을 건너뛰고 한 번에 최신 버전으로 올리는 것은 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 부팅 실패 위험이 있습니다. + +| 현재 버전 | 권장 경로 | +|----------|----------| +| **beta.5 이상** | `sudo php artisan core:update` 1회로 최신 버전까지 자동 처리 | +| **beta.3 / beta.4** | beta.5 → 최신 순서로 2회 분할 실행. 각 단계 후 `.env` `APP_VERSION` 이 자동 갱신되므로 같은 명령을 반복 실행 | +| **beta.1 / beta.2** | beta.2 → beta.3 → beta.4 → beta.5 → 최신 순으로 단계별 진행 | +| **beta.4 도중 fatal 후 중단** | `.env` `APP_VERSION` 을 변경하지 말고 `php artisan core:update --force` 재실행. 새 자식 프로세스가 신버전 메모리로 부팅되어 남은 스텝이 멱등 적용됨 | + +특정 베타 버전으로의 단계 업그레이드는 `--source=` 또는 `--zip=` 옵션으로 해당 버전 릴리스 ZIP 을 지정하여 진행합니다. + +> 사용자군별 상세 분기는 [CHANGELOG.md](CHANGELOG.md) 의 해당 버전 "Upgrade Notice" 섹션을 참고하세요. + +### 권한/캐시 트러블슈팅 + +업그레이드 도중 또는 직후 사이트가 응답하지 않는 경우, 다음을 확인합니다. 본 절의 `chmod`/`chown`/`chgrp` 명령은 POSIX 권한 모델 (Linux/macOS/BSD) 환경 전용이며, Windows 환경에서는 해당하지 않습니다. + +#### `.env` 읽기 실패 (`Permission denied`) + +```bash +ls -l .env +# -rw------- 1 www-data www-data ... .env ← 세 번째 컬럼이 소유자 +``` + +대부분의 경우 `sudo php artisan core:update` 로 재실행하면 root 권한으로 소유자에 관계없이 접근됩니다. 권한 보안을 강화하려면 본 문서 [`.env` 권한 강화 (선택)](#env-권한-강화-선택) 섹션의 `0600` / `0640` 가이드를 참고하세요. 명령줄 사용자와 웹 서버 사용자가 다른 환경에서는 `0640` + 웹 서버 그룹 부여가 안전한 절충안입니다. + +#### `bootstrap/cache` 쓰기 실패 + +```bash +sudo chown -R www-data:www-data bootstrap/cache storage +``` + +소유자를 웹 서버 사용자로 일괄 정렬한 뒤 업그레이드를 재실행합니다. + +#### 업그레이드 도중 자동 롤백 후 부팅 실패 + +beta.6 이상에서는 자동 롤백 후 잔존 파일을 추가 진단하는 `hotfix:rollback-stale-files` 커맨드가 제공됩니다. + +```bash +# 진단 모드 (실제 삭제 없음) +sudo php artisan hotfix:rollback-stale-files + +# 운영자 확인 후 실제 정리 +sudo php artisan hotfix:rollback-stale-files --prune +``` + +### 업그레이드 후 확인 + +업그레이드가 끝나면 다음을 점검합니다. + +1. 관리자 페이지 > **환경설정 > 정보** 에서 G7 버전이 갱신되었는지 확인 +2. **확장 > 모듈/플러그인/템플릿** 목록이 모두 정상 표시되는지 확인 +3. 사용자 페이지 진입하여 기존 콘텐츠(게시판/상품/페이지) 가 정상 노출되는지 확인 +4. `storage/logs/laravel.log` 에 신규 에러가 누적되지 않는지 확인 + +--- + ## 프로덕션 환경 추가 설정 프로덕션 환경에서는 아래 항목을 추가로 설정하는 것을 권장합니다. @@ -346,6 +497,30 @@ http://도메인/install 프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요. +### `.env` 권한 강화 (선택) + +`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다. + +- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. 다만 `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다. 소유자가 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다. + +- **단일 사용자 환경** (예: 카페24 등 명령줄 사용자와 웹 서버 사용자가 같은 경우): + + ```bash + sudo chown www-data:www-data .env + sudo chmod 600 .env + ``` + +- **자체 구축 서버** (명령줄 사용자 `jjh` 등과 웹 서버 사용자 `www-data` 가 다른 경우): + + ```bash + sudo chgrp www-data .env + sudo chmod 640 .env + ``` + + 웹 서버는 그룹 멤버로 읽기만 허용되고, 명령줄 사용자는 직접 수정할 수 있는 절충안입니다. + +인스톨러 Step 5 완료 화면은 현재 `.env` 권한이 `0600` 이 아닐 경우 위 두 명령 예시를 조건부로 표시합니다. + ### 데몬 프로세스 상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다. diff --git a/README.md b/README.md index 6ff3f43e..ca206c54 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php index eb42c6a9..ee1858cd 100644 --- a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php +++ b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php @@ -459,4 +459,5 @@ trait BundledExtensionUpdatePrompt 'has_updates' => true, ]; } + } diff --git a/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php index d48df1ff..66dfb893 100644 --- a/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php +++ b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php @@ -174,4 +174,5 @@ class ExecuteBundledUpdatesCommand extends Command return $failed > 0 ? self::FAILURE : self::SUCCESS; } + } diff --git a/app/Providers/InstallerRuntimeServiceProvider.php b/app/Providers/InstallerRuntimeServiceProvider.php index 02c80949..b0ddf1e5 100644 --- a/app/Providers/InstallerRuntimeServiceProvider.php +++ b/app/Providers/InstallerRuntimeServiceProvider.php @@ -40,6 +40,45 @@ class InstallerRuntimeServiceProvider extends ServiceProvider $runtimePath = base_path(self::RUNTIME_PATH_RELATIVE); if (! is_file($runtimePath)) { + // beta.7 한시 보정 — 이미 finalize 완료된 환경의 stale process ENV 회귀 차단. + // + // 배경 (이슈 #371): beta.4~6 손상 환경에서 beta.7 코어 업그레이드의 spawn 자식 1 + // (core:execute-upgrade-steps) 이 .env 머지 + runtime.php 삭제를 마쳐도, 부모 + // 프로세스의 process ENV (getenv / $_ENV / $_SERVER) 는 부팅 시점의 stale 값 + // (.env.example fallback: DB_WRITE_USERNAME=root) 그대로 유지된다. 그 직후 부모가 + // 띄우는 spawn 자식 2 (core:execute-bundled-updates) 에 stale ENV 가 그대로 + // 상속되어 Laravel Dotenv::createImmutable() 이 디스크의 정합 .env 값을 보지 못해 + // env('DB_WRITE_USERNAME') 이 stale 'root' 반환 → CoreServiceProvider::boot() 의 + // isDatabaseConnectionValid() 가드가 트리거되어 확장 로딩 skip + ERROR 출력 → + // 번들 일괄 업데이트 전체 실패. + // + // 본 보정은 자식 2 가 부팅할 때 InstallerRuntimeServiceProvider::register() (가장 먼저 + // 실행되는 ServiceProvider) 안에서 다음 4개 조건을 모두 만족하면 디스크 .env 를 + // 직접 파싱하여 process ENV 를 갱신한다: + // (1) runtime.php 부재 (이미 finalize 완료된 상태 — 본 분기에 도달) + // (2) .env 존재 + // (3) .env 의 INSTALLER_COMPLETED 가 truthy (finalize 성공 신호) + // (4) .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 빈 값 아님) + // AND process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값) + // + // 정상 환경에서는 (4) 의 process ENV stale 시그니처가 false 이므로 보정 미발동. + $this->recoverStaleProcessEnvFromDiskEnv(); + + // 본 PR 부수효과 정리 — beta.7 업그레이드 흐름의 모든 root 권한 자식 spawn + // 부팅마다 자동으로 storage/framework/cache/data/ 하위 root 소유 디렉토리를 + // 즉시 chown. 자식 1 (FinalizeOrphanedInstallerRuntime) 종료 후에 자식 2~N + // (번들 update / 후속 자식들) 이 root 권한으로 캐시 쓰기를 트리거하더라도 + // 그 자식 자체의 ServiceProvider 부팅 시점에 본 보정이 발화하여 정리. + // + // 가드: + // (1) runtime.php 부재 (이미 finalize 완료 — 본 분기에 도달) + // (2) 디스크 config/app.php 의 version === '7.0.0-beta.7' (recover 와 동일 가드) + // (3) posix_geteuid() === 0 (root 권한 자식만) + // + // 정상 환경(beta.3 / beta.8+) 또는 PHP-FPM 워커(www-data 권한) 는 (2) 또는 (3) + // 가드로 자동 비활성화. + $this->normalizeRootOwnedCacheDirs(); + return; } @@ -53,6 +92,328 @@ class InstallerRuntimeServiceProvider extends ServiceProvider $this->applyAppKey($runtime); } + /** + * beta.7 한시 보정 — 디스크 .env 가 정합이고 process ENV 가 stale 일 때만 갱신. + * + * 트리거 조건이 매우 보수적이라 정상 환경 부팅 비용은 .env 의 parseEnvFile 1회 + 키 4~5개 + * 비교. 손상 환경에서만 putenv/$_ENV/$_SERVER 갱신이 일어남. + */ + private function recoverStaleProcessEnvFromDiskEnv(): void + { + // beta.7 한시 가드 — 본 보정은 beta.4~6 finalize 결함 환경의 회귀 차단 전용. + // 디스크 config/app.php 의 'version' 값을 라인 매칭으로 추출 (메모리/env 무관) + // 하여 정확히 beta.7 로 업그레이드 중인 경우에만 발화. 메모리 config('app.version') + // 은 부모 stale ENV 의 APP_VERSION 을 따라가므로 신뢰할 수 없음. + $coreVersion = $this->readCoreVersionFromDisk(); + if ($coreVersion !== '7.0.0-beta.7') { + return; + } + + $envPath = base_path('.env'); + + if (! is_file($envPath)) { + return; + } + + // parse_ini_file 은 .env 가 PHP INI 와 100% 호환되지 않는 케이스 (예약어 값, + // 일부 특수문자) 에서 실패 가능. 라인 단위 직접 파싱으로 호환성 함정 회피. + $parsed = $this->parseEnvFile($envPath); + + if (! is_array($parsed) || $parsed === []) { + return; + } + + // 조건 (3): INSTALLER_COMPLETED 가 truthy 여야 함 (finalize 완료 신호) + $completedFlagRaw = (string) ($parsed['INSTALLER_COMPLETED'] ?? ''); + $completedFlag = strtolower(trim($completedFlagRaw, " \t\"'")); + if (! in_array($completedFlag, ['true', '1', 'yes'], true)) { + return; + } + + // 조건 (4-a): .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 비어있지 않음) + $diskUsernameRaw = (string) ($parsed['DB_WRITE_USERNAME'] ?? ''); + $diskUsername = $this->unwrapEnvValue($diskUsernameRaw); + if ($diskUsername === '' || $diskUsername === 'root') { + return; + } + + // 조건 (4-b): process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값). + // + // bootstrap/app.php 의 Env::disablePutenv() 로 인해 getenv 가 빈 값일 수 있으므로, + // getenv / $_ENV / $_SERVER 셋 중 어느 하나라도 정합 (root 아님 + 빈 값 아님) 이면 + // 정상 환경으로 판정하여 보정 skip. 셋 다 stale (root 또는 빈) 일 때만 진입. + $envUsername = (string) getenv('DB_WRITE_USERNAME'); + $envUsernameSE = (string) ($_ENV['DB_WRITE_USERNAME'] ?? ''); + $serverUsernameSE = (string) ($_SERVER['DB_WRITE_USERNAME'] ?? ''); + foreach ([$envUsername, $envUsernameSE, $serverUsernameSE] as $candidate) { + if ($candidate !== '' && $candidate !== 'root') { + return; + } + } + + // 4개 조건 모두 만족 — process ENV 갱신 + $refreshableKeys = [ + 'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD', + 'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD', + 'DB_PREFIX', 'APP_KEY', + ]; + + foreach ($refreshableKeys as $key) { + if (! array_key_exists($key, $parsed)) { + continue; + } + $value = $this->unwrapEnvValue((string) $parsed[$key]); + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + + // Laravel Config 도 동기 갱신 — register() 시점에 LoadConfiguration 이 이미 실행되어 + // process ENV 만 갱신해도 config('database.connections.mysql.*') 가 stale 값 + // (root) 으로 캐시된 상태이기 때문에 직접 덮어써야 한다. + $this->applyConfigFromParsedEnv($parsed); + } + + /** + * recover 분기 — 파싱된 .env 의 DB_* / APP_KEY / DB_PREFIX 를 Laravel Config 에 적용. + * + * 기존 `applyDatabaseConfig` 와 동등한 키 경로 / 구조 (`database.connections.mysql.{read,write}.*`). + * + * @param array $parsed + */ + private function applyConfigFromParsedEnv(array $parsed): void + { + $writeHost = $parsed['DB_WRITE_HOST'] ?? null; + if ($writeHost !== null && $writeHost !== '') { + Config::set('database.connections.mysql.write.host', [$writeHost]); + Config::set('database.connections.mysql.write.port', $parsed['DB_WRITE_PORT'] ?? '3306'); + Config::set('database.connections.mysql.write.database', $parsed['DB_WRITE_DATABASE'] ?? ''); + Config::set('database.connections.mysql.write.username', $parsed['DB_WRITE_USERNAME'] ?? ''); + Config::set('database.connections.mysql.write.password', $parsed['DB_WRITE_PASSWORD'] ?? ''); + } + + $readHost = $parsed['DB_READ_HOST'] ?? null; + if ($readHost !== null && $readHost !== '') { + Config::set('database.connections.mysql.read.host', [$readHost]); + Config::set('database.connections.mysql.read.port', $parsed['DB_READ_PORT'] ?? '3306'); + Config::set('database.connections.mysql.read.database', $parsed['DB_READ_DATABASE'] ?? ''); + Config::set('database.connections.mysql.read.username', $parsed['DB_READ_USERNAME'] ?? ''); + Config::set('database.connections.mysql.read.password', $parsed['DB_READ_PASSWORD'] ?? ''); + } elseif ($writeHost !== null && $writeHost !== '') { + // read 가 별도 지정되지 않은 경우 write 값으로 동기화 (단일 DB 시나리오) + Config::set('database.connections.mysql.read.host', [$writeHost]); + Config::set('database.connections.mysql.read.port', $parsed['DB_WRITE_PORT'] ?? '3306'); + Config::set('database.connections.mysql.read.database', $parsed['DB_WRITE_DATABASE'] ?? ''); + Config::set('database.connections.mysql.read.username', $parsed['DB_WRITE_USERNAME'] ?? ''); + Config::set('database.connections.mysql.read.password', $parsed['DB_WRITE_PASSWORD'] ?? ''); + } + + if (isset($parsed['DB_PREFIX'])) { + Config::set('database.connections.mysql.prefix', $parsed['DB_PREFIX']); + } + + $appKey = $parsed['APP_KEY'] ?? null; + if (is_string($appKey) && $appKey !== '') { + Config::set('app.key', $appKey); + } + + // 기존 connection 인스턴스의 stale credential 캐시 폐기 — 다음 DB::connection() 호출 시 + // 새 Config 로 재인스턴스화되도록 강제. config.write.username 이 stale root 로 + // 남아 있던 회귀의 부속 원인 차단 (DB Manager 의 connection 캐시). + try { + \Illuminate\Support\Facades\DB::purge('mysql'); + } catch (\Throwable $e) { + // non-fatal — Config 만 갱신된 상태로도 다음 connection 시 정합 값 사용 + } + } + + /** + * beta.7 업그레이드 자식 spawn 부팅마다 자동 발동 — storage/framework/cache/data/ + * 하위 root 소유 디렉토리/파일을 정상 owner/group 으로 chown. + * + * 본 PR 의 recover 가 발화하면서 부수적으로 만들 수 있는 root 권한 캐시 디렉토리를 + * 자식 자체의 부팅 시점에 자동 청소한다. 안전망 역할 — recover 가 정상 발화하여 + * loadModules 가 정상 권한 캐시를 생성하면 본 메서드는 found=0 으로 no-op. + * + * 가드: + * - 디스크 config/app.php version === '7.0.0-beta.7' (recover 와 동일) + * - posix_geteuid() === 0 (root 권한 자식만 — 운영자 의도 권한 보존) + * - cache/data 자신이 정합 owner/group (chown 기준값 추정) + */ + private function normalizeRootOwnedCacheDirs(): void + { + // beta.7 한정 — recover 와 동일 가드 + $coreVersion = $this->readCoreVersionFromDisk(); + if ($coreVersion !== '7.0.0-beta.7') { + return; + } + + if (! function_exists('posix_geteuid') || ! function_exists('chown') || ! function_exists('chgrp')) { + return; + } + + // root 권한 프로세스만 (PHP-FPM 워커는 www-data → euid != 0 → skip) + if (posix_geteuid() !== 0) { + return; + } + + $cacheRoot = base_path('storage/framework/cache/data'); + if (! is_dir($cacheRoot)) { + return; + } + + // 정상 owner/group 추정 (cache/data 자신이 SSoT) + $targetUid = @fileowner($cacheRoot); + $targetGid = @filegroup($cacheRoot); + if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) { + // cache/data 자신이 root 소유면 추정 실패 — bootstrap/cache fallback + $bootstrapCache = base_path('bootstrap/cache'); + if (is_dir($bootstrapCache)) { + $targetUid = @fileowner($bootstrapCache); + $targetGid = @filegroup($bootstrapCache); + } + if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) { + return; + } + } + + // 1depth 만 빠르게 점검 — root 소유 디렉토리 발견 시 재귀 chown + $handle = @opendir($cacheRoot); + if ($handle === false) { + return; + } + while (($name = readdir($handle)) !== false) { + if ($name === '.' || $name === '..') { + continue; + } + $path = $cacheRoot.DIRECTORY_SEPARATOR.$name; + $uid = @fileowner($path); + $gid = @filegroup($path); + if ($uid !== 0 && $gid !== 0) { + continue; + } + // root 소유 항목만 보정 — 사용자가 의도해서 만든 다른 소유자는 보존 + $this->chownRecursive($path, $targetUid, $targetGid); + } + closedir($handle); + } + + /** + * 디렉토리/파일을 재귀적으로 chown + chgrp. + * + * @return bool 모든 항목 성공 시 true + */ + private function chownRecursive(string $path, int $uid, int $gid): bool + { + $ok = @chown($path, $uid) && @chgrp($path, $gid); + if (! is_dir($path) || is_link($path)) { + return $ok; + } + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iter as $entry) { + /** @var \SplFileInfo $entry */ + $childPath = $entry->getPathname(); + $ok = (@chown($childPath, $uid) && @chgrp($childPath, $gid)) && $ok; + } + + return $ok; + } + + /** + * 디스크 `config/app.php` 의 `'version' => ...` 라인을 정규식 매칭으로 추출. + * + * 메모리 config('app.version') 은 부모 stale ENV 의 APP_VERSION 을 따르므로 + * 본 fix 의 자기 활성화 가드로 사용 불가. 디스크 SSoT (config/app.php) 직접 읽기로 + * 코어 업그레이드 시점의 실제 적용 버전 확보. + * + * @return string|null 추출 실패 시 null + */ + private function readCoreVersionFromDisk(): ?string + { + $configPath = base_path('config/app.php'); + if (! is_file($configPath)) { + return null; + } + $content = @file_get_contents($configPath); + if ($content === false) { + return null; + } + // 'version' => env('APP_VERSION', '7.0.0-beta.7'), + if (preg_match("/'version'\s*=>\s*env\(\s*'APP_VERSION'\s*,\s*'([^']+)'\s*\)/", $content, $m)) { + return $m[1]; + } + // fallback: 'version' => '7.0.0-beta.7', + if (preg_match("/'version'\s*=>\s*'([^']+)'/", $content, $m)) { + return $m[1]; + } + + return null; + } + + /** + * .env 파일을 라인 단위로 직접 파싱하여 key=>value 배열 반환. + * + * parse_ini_file 의 PHP INI 호환성 함정을 회피하기 위해 자체 파싱: + * - 빈 줄 / `#` 또는 `;` 로 시작하는 주석 줄 무시 + * - 첫 `=` 기준으로 key/value 분할 (값에 `=` 포함 가능: APP_KEY=base64:...= 등) + * - 외곽 인용부호 (single/double) 제거 — unwrapEnvValue 사용 + * - 키 이름은 [A-Za-z_][A-Za-z0-9_]* 만 인정 + * + * @return array + */ + private function parseEnvFile(string $envPath): array + { + $content = @file_get_contents($envPath); + if ($content === false) { + return []; + } + + $lines = preg_split('/\r\n|\r|\n/', $content); + if ($lines === false) { + return []; + } + + $result = []; + foreach ($lines as $line) { + $trimmed = ltrim($line); + if ($trimmed === '' || $trimmed[0] === '#' || $trimmed[0] === ';') { + continue; + } + $eqPos = strpos($trimmed, '='); + if ($eqPos === false || $eqPos === 0) { + continue; + } + $key = rtrim(substr($trimmed, 0, $eqPos)); + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { + continue; + } + $value = substr($trimmed, $eqPos + 1); + // 줄 끝의 행간 공백 제거 (인용된 값은 인용부호 안의 내용 보존) + $value = rtrim($value, " \t"); + $value = $this->unwrapEnvValue($value); + $result[$key] = $value; + } + + return $result; + } + + private function unwrapEnvValue(string $value): string + { + $length = strlen($value); + if ($length >= 2) { + $first = $value[0]; + $last = $value[$length - 1]; + if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { + return substr($value, 1, $length - 2); + } + } + + return $value; + } + /** * runtime 배열의 DB 자격증명을 config('database.connections.mysql.*') 에 주입. * diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index cea83396..f84f704a 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -95,7 +95,7 @@ class CoreUpdateService $log = function (string $level, string $message) use ($logger): void { if ($logger === null) { - Log::$level($message); + Log::channel('upgrade')->$level($message); return; } @@ -109,7 +109,7 @@ class CoreUpdateService return; } - Log::$level($message); + Log::channel('upgrade')->$level($message); }; $log('info', sprintf( @@ -336,7 +336,7 @@ class CoreUpdateService File::put($cachePath, $content); } } catch (\Exception $e) { - Log::warning('원격 CHANGELOG 캐시 실패', ['error' => $e->getMessage()]); + Log::channel('upgrade')->warning('원격 CHANGELOG 캐시 실패', ['error' => $e->getMessage()]); } } @@ -404,7 +404,7 @@ class CoreUpdateService ->connectTimeout(5) ->get($apiUrl); } catch (ConnectionException $e) { - Log::warning(__('settings.core_update.log_api_call_failed'), [ + Log::channel('upgrade')->warning(__('settings.core_update.log_api_call_failed'), [ 'url' => $apiUrl, 'error' => $e->getMessage(), ]); @@ -417,7 +417,7 @@ class CoreUpdateService $apiMessage = is_array($data) && isset($data['message']) ? $data['message'] : ''; if ($statusCode === 401 || $statusCode === 403) { - Log::warning(__('settings.core_update.log_auth_failed'), [ + Log::channel('upgrade')->warning(__('settings.core_update.log_auth_failed'), [ 'url' => $apiUrl, 'status' => $statusCode, 'has_token' => $token !== '', @@ -436,7 +436,7 @@ class CoreUpdateService if ($repoExists) { // 저장소는 존재하지만 릴리스가 없음 - Log::info(__('settings.core_update.log_not_found'), [ + Log::channel('upgrade')->info(__('settings.core_update.log_not_found'), [ 'url' => $apiUrl, 'reason' => 'no_releases', ]); @@ -445,7 +445,7 @@ class CoreUpdateService } // 저장소 자체를 찾을 수 없음 - Log::warning(__('settings.core_update.log_not_found'), [ + Log::channel('upgrade')->warning(__('settings.core_update.log_not_found'), [ 'url' => $apiUrl, 'has_token' => $token !== '', 'api_message' => $apiMessage, @@ -458,7 +458,7 @@ class CoreUpdateService } if ($statusCode !== 200) { - Log::warning(__('settings.core_update.log_unexpected_status'), [ + Log::channel('upgrade')->warning(__('settings.core_update.log_unexpected_status'), [ 'url' => $apiUrl, 'status' => $statusCode, 'api_message' => $apiMessage, @@ -473,7 +473,7 @@ class CoreUpdateService return ['version' => null, 'error' => __('settings.core_update.no_releases_found')]; } catch (\Exception $e) { - Log::error(__('settings.core_update.log_version_check_error'), ['error' => $e->getMessage()]); + Log::channel('upgrade')->error(__('settings.core_update.log_version_check_error'), ['error' => $e->getMessage()]); return ['version' => null, 'error' => __('settings.core_update.github_api_failed')]; } @@ -971,7 +971,7 @@ class CoreUpdateService } $dest = base_path($name); - Log::warning('[core-update] targets allowlist 누락 신규 항목 자동 적용', [ + Log::channel('upgrade')->warning('[core-update] targets allowlist 누락 신규 항목 자동 적용', [ 'name' => $name, 'reason' => 'parent process targets list did not include this path; falling back to source auto-discovery', ]); @@ -1113,7 +1113,7 @@ class CoreUpdateService } if (md5_file($pendingJson) !== md5_file($baseJson)) { - Log::info('코어 업데이트: composer.json 변경 감지'); + Log::channel('upgrade')->info('코어 업데이트: composer.json 변경 감지'); return false; } @@ -1123,20 +1123,20 @@ class CoreUpdateService $baseLockExists = file_exists($baseLock); if ($pendingLockExists !== $baseLockExists) { - Log::info('코어 업데이트: composer.lock 존재 여부 불일치'); + Log::channel('upgrade')->info('코어 업데이트: composer.lock 존재 여부 불일치'); return false; } if ($pendingLockExists && $baseLockExists) { if (md5_file($pendingLock) !== md5_file($baseLock)) { - Log::info('코어 업데이트: composer.lock 변경 감지'); + Log::channel('upgrade')->info('코어 업데이트: composer.lock 변경 감지'); return false; } } - Log::info('코어 업데이트: composer 의존성 변경 없음 — 스킵 가능'); + Log::channel('upgrade')->info('코어 업데이트: composer 의존성 변경 없음 — 스킵 가능'); return true; } @@ -1238,7 +1238,7 @@ class CoreUpdateService fclose($pipes[2]); $exitCode = proc_close($process); - Log::info('코어 업데이트: composer install 완료', [ + Log::channel('upgrade')->info('코어 업데이트: composer install 완료', [ 'working_dir' => $workingDir, 'exit_code' => $exitCode, 'output' => $output, @@ -1410,7 +1410,7 @@ class CoreUpdateService $definedRoleIdentifiers, ); - Log::info('코어 역할/권한 동기화 완료', [ + Log::channel('upgrade')->info('코어 역할/권한 동기화 완료', [ 'stale_permissions_deleted' => $deletedPerms, 'stale_roles_deleted' => $deletedRoles, ]); @@ -1447,7 +1447,7 @@ class CoreUpdateService $currentSlugs, ); - Log::info('코어 메뉴 동기화 완료', ['stale_deleted' => $deleted]); + Log::channel('upgrade')->info('코어 메뉴 동기화 완료', ['stale_deleted' => $deleted]); } /** @@ -1486,14 +1486,14 @@ class CoreUpdateService { $path = config_path('core.php'); if (! File::exists($path)) { - Log::warning('reloadCoreConfigAndResync: config/core.php 미존재 — 스킵'); + Log::channel('upgrade')->warning('reloadCoreConfigAndResync: config/core.php 미존재 — 스킵'); return; } $fresh = require $path; if (! is_array($fresh)) { - Log::warning('reloadCoreConfigAndResync: config/core.php 반환값이 배열이 아님 — 스킵'); + Log::channel('upgrade')->warning('reloadCoreConfigAndResync: config/core.php 반환값이 배열이 아님 — 스킵'); return; } @@ -1503,13 +1503,13 @@ class CoreUpdateService try { $this->syncCoreRolesAndPermissions(); } catch (\Throwable $e) { - Log::warning('reloadCoreConfigAndResync: 권한 재동기화 실패', ['error' => $e->getMessage()]); + Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 권한 재동기화 실패', ['error' => $e->getMessage()]); } try { $this->syncCoreMenus(); } catch (\Throwable $e) { - Log::warning('reloadCoreConfigAndResync: 메뉴 재동기화 실패', ['error' => $e->getMessage()]); + Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 메뉴 재동기화 실패', ['error' => $e->getMessage()]); } try { @@ -1517,7 +1517,7 @@ class CoreUpdateService (new NotificationDefinitionSeeder())->run(); } } catch (\Throwable $e) { - Log::warning('reloadCoreConfigAndResync: 알림 정의 재시딩 실패', ['error' => $e->getMessage()]); + Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 알림 정의 재시딩 실패', ['error' => $e->getMessage()]); } try { @@ -1525,7 +1525,7 @@ class CoreUpdateService (new IdentityPolicySeeder())->run(); } } catch (\Throwable $e) { - Log::warning('reloadCoreConfigAndResync: IDV 정책 재시딩 실패', ['error' => $e->getMessage()]); + Log::channel('upgrade')->warning('reloadCoreConfigAndResync: IDV 정책 재시딩 실패', ['error' => $e->getMessage()]); } try { @@ -1533,7 +1533,7 @@ class CoreUpdateService (new IdentityMessageDefinitionSeeder())->run(); } } catch (\Throwable $e) { - Log::warning('reloadCoreConfigAndResync: IDV 메시지 정의 재시딩 실패', ['error' => $e->getMessage()]); + Log::channel('upgrade')->warning('reloadCoreConfigAndResync: IDV 메시지 정의 재시딩 실패', ['error' => $e->getMessage()]); } } @@ -1584,7 +1584,7 @@ class CoreUpdateService ); } - Log::warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.'); + Log::channel('upgrade')->warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.'); } $upgradesPath = base_path('upgrades'); @@ -1653,7 +1653,7 @@ class CoreUpdateService foreach ($steps as $version => $step) { $onStep?->__invoke($version); - Log::info("코어 업그레이드 스텝 실행: {$version}"); + Log::channel('upgrade')->info("코어 업그레이드 스텝 실행: {$version}"); $step->run($context->withCurrentStep($version)); } } @@ -1715,7 +1715,7 @@ class CoreUpdateService '--refresh' => 15, ]); - Log::info('코어 업데이트: 유지보수 모드 활성화', ['secret' => $secret]); + Log::channel('upgrade')->info('코어 업데이트: 유지보수 모드 활성화', ['secret' => $secret]); return $secret; } @@ -1726,7 +1726,7 @@ class CoreUpdateService public function disableMaintenanceMode(): void { Artisan::call('up'); - Log::info('코어 업데이트: 유지보수 모드 비활성화'); + Log::channel('upgrade')->info('코어 업데이트: 유지보수 모드 비활성화'); } /** @@ -2017,7 +2017,7 @@ class CoreUpdateService } if ($truncated) { - Log::warning('snapshotOwnershipDetailed: 50000 항목 초과 — 첫 50000 항목만 스냅샷', [ + Log::channel('upgrade')->warning('snapshotOwnershipDetailed: 50000 항목 초과 — 첫 50000 항목만 스냅샷', [ 'collected' => count($snapshot), 'paths' => $paths, ]); @@ -2167,7 +2167,7 @@ class CoreUpdateService $report = FilePermissionHelper::chownRecursiveDetailed($path, $owner, $group, respectPreservationMarker: true); if ($report['changed'] > 0) { - Log::info('코어 업데이트: 소유권 복원', [ + Log::channel('upgrade')->info('코어 업데이트: 소유권 복원', [ 'target' => $target, 'owner' => $owner, 'group' => $group, @@ -2226,14 +2226,14 @@ class CoreUpdateService } if ($groupWritableChanged > 0) { - Log::info('코어 업데이트: 그룹 쓰기 권한 정상화', [ + Log::channel('upgrade')->info('코어 업데이트: 그룹 쓰기 권한 정상화', [ 'targets' => $groupWritableTargets, 'changed_entries' => $groupWritableChanged, ]); } if ($restoredCount > 0) { - Log::info('코어 업데이트: 소유권 복원 완료', [ + Log::channel('upgrade')->info('코어 업데이트: 소유권 복원 완료', [ 'restored_entries_total' => $restoredCount, 'targets' => $targets, ]); @@ -2335,7 +2335,7 @@ class CoreUpdateService } if ($changed > 0) { - Log::info('코어 업데이트: 항목별 정확 복원 완료', [ + Log::channel('upgrade')->info('코어 업데이트: 항목별 정확 복원 완료', [ 'snapshot_items' => count($detailedSnapshot), 'changed_attributes' => $changed, ]); @@ -2348,7 +2348,7 @@ class CoreUpdateService 'failed' => $failed, 'failed_paths' => $failedPaths, ]; - Log::warning('코어 업데이트: 항목별 복원 부분 실패', [ + Log::channel('upgrade')->warning('코어 업데이트: 항목별 복원 부분 실패', [ 'failed_count' => $failed, 'first_failed' => $failedPaths[0] ?? null, ]); @@ -2389,7 +2389,7 @@ class CoreUpdateService File::put($reportPath, $content); - Log::error('코어 업데이트 실패', [ + Log::channel('upgrade')->error('코어 업데이트 실패', [ 'from' => $fromVersion, 'to' => $toVersion, 'error' => $exception->getMessage(), diff --git a/config/app.php b/config/app.php index d5dfd02e..d5de7e39 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.0-beta.6'), + 'version' => env('APP_VERSION', '7.0.0-beta.7'), /* |-------------------------------------------------------------------------- diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 31d4a144..348bd605 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.0-beta.2] - 2026-05-15 + +### Added + +- 코어 업데이트 안내 모달의 신규 텍스트 5건에 대한 일본어 번역 추가 (`sudo` 권장 안내 / 외부 업그레이드 가이드 링크 라벨 등). + ## [1.0.0-beta.1] - 2026-05-11 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index a3479bb0..e3e813be 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1838,7 +1838,12 @@ "manual_update_title": "手動アップデート (ZIP 拡張未インストール環境)", "manual_update_desc": "サーバーに PHP zip 拡張または unzip コマンドがない場合、GitHub からリリース ZIP を直接ダウンロードしてアップデートできます。", "manual_update_step1": "# 1. GitHub リリースページから ZIP をダウンロード後、サーバーで圧縮解除", - "manual_update_step2": "# 2. 圧縮解除されたパスを指定してアップデートを実行" + "manual_update_step2": "# 2. 圧縮解除されたパスを指定してアップデートを実行", + "update_guide_cmd_comment": "# 管理者権限での実行を推奨します (Linux/macOS)", + "update_guide_cmd": "$ sudo php artisan core:update", + "manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted", + "upgrade_guide_link_desc": "運用環境別の権限·ユーザーグループ別の詳細な手順はアップグレードガイドを参照してください。", + "upgrade_guide_link_label": "アップグレードガイド全体を表示" }, "modals": { "password_confirm_title": "パスワード確認", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json index 66e19198..438f1d9f 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-admin_basic", diff --git a/public/install/api/_guard.php b/public/install/api/_guard.php index f8ce22a2..d5167e00 100644 --- a/public/install/api/_guard.php +++ b/public/install/api/_guard.php @@ -79,3 +79,65 @@ if (!function_exists('installer_guard_or_410')) { exit; } } + +if (!function_exists('installer_finalize_is_completed')) { + /** + * finalize-env.php 전용 멱등 차단 신호. + * + * 본 함수는 `.env` 의 `INSTALLER_COMPLETED=true` 단독으로만 차단을 판정한다. + * `storage/app/g7_installed` 락 파일은 finalize 호출 직전 단계인 + * `complete_flag` task 가 먼저 생성하므로 차단 사유에서 제외한다. + */ + function installer_finalize_is_completed(): bool + { + $basePath = installer_resolve_base_path(); + $envFile = $basePath . '/.env'; + + if (!is_file($envFile)) { + return false; + } + + $env = @parse_ini_file($envFile, false, INI_SCANNER_RAW); + if (!is_array($env)) { + return false; + } + + $flag = strtolower(trim((string) ($env['INSTALLER_COMPLETED'] ?? ''))); + $flag = trim($flag, "\"'"); + + return in_array($flag, ['true', '1', 'yes'], true); + } +} + +if (!function_exists('installer_guard_finalize_or_410')) { + /** + * finalize-env.php 전용 진입 가드. + * + * 차단 조건: `.env` 의 `INSTALLER_COMPLETED=true` 단독. `g7_installed` 락 파일 + * 존재는 차단 사유가 아니다 — complete_flag task 가 락 파일을 먼저 만든 직후 + * 본 엔드포인트가 호출되어 `.env` 머지를 수행해야 하기 때문이다. + * + * RCE 공격 표면이 없는 finalize 전용으로 한정 — 일반 인스톨러 엔드포인트는 + * 계속 `installer_guard_or_410()` 을 사용한다. + */ + function installer_guard_finalize_or_410(): void + { + if (!installer_finalize_is_completed()) { + return; + } + + if (!headers_sent()) { + http_response_code(410); + header('Content-Type: application/json; charset=utf-8'); + header('X-Installer: disabled'); + header('Cache-Control: no-store'); + } + + echo json_encode([ + 'success' => false, + 'message' => 'Finalize already completed.', + ], JSON_UNESCAPED_UNICODE); + + exit; + } +} diff --git a/public/install/api/finalize-env.php b/public/install/api/finalize-env.php index 3f38e089..2c41965e 100644 --- a/public/install/api/finalize-env.php +++ b/public/install/api/finalize-env.php @@ -22,7 +22,11 @@ require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/installer-runtime.php'; require_once __DIR__ . '/../includes/installer-state.php'; require_once __DIR__ . '/_guard.php'; -installer_guard_or_410(); +// finalize 전용 가드 — `.env` 의 INSTALLER_COMPLETED=true 단독으로만 차단한다. +// 일반 인스톨러 엔드포인트의 `installer_guard_or_410()` 은 `g7_installed` 락 파일도 +// 차단 사유로 삼는데, 그 락 파일은 finalize 호출 직전 단계의 complete_flag task 가 +// 먼저 생성하므로 자가 차단 회귀가 발생한다 (이슈 #371). +installer_guard_finalize_or_410(); // --------------------------------------------------------------------------- // 1. 응답을 즉시 송출하여 브라우저가 완료 UI 를 유지할 수 있게 한다. @@ -68,7 +72,7 @@ try { $envBase = generateEnvContent(); if ($envBase === null) { - error_log('[finalize-env] generateEnvContent() returned null — .env.example missing'); + addLog('[finalize-env] generateEnvContent() returned null — .env.example missing'); return; } @@ -79,25 +83,72 @@ try { // ServeCommand 의 mtime watcher 가 다중 재시작을 일으키지 않는다. // atomic rename 대신 file_put_contents 를 사용하여 부모 디렉토리(프로젝트 루트) // 쓰기 권한 요구를 회피 — 기존 인스톨러 권한 안내 (chmod 664 .env + chgrp) 만으로 충분. + // + // 권한 정책 (#371): finalize-env.php 는 chmod 0600 시도를 수행하지 않는다. + // PHP-FPM(www-data) 가 .env 소유자(예: jjh) 와 다른 자체 구축 환경에서는 POSIX 상 + // chmod 가 거부되며, 또한 인스톨러가 임의로 0600 으로 깎으면 운영자가 의도해서 + // 설정한 0664/0640 권한이 손상된다. file_put_contents 는 기존 inode 의 권한을 + // 변경하지 않으므로, 인스톨러 안내 단계의 권한 (chgrp www-data + chmod 664) 이 + // 그대로 유지된다. 추가 보안 강화는 Step 6 완료 화면 안내 + INSTALL.md 운영 + // 가이드에 위임. if (@file_put_contents($envPath, $envContent, LOCK_EX) === false) { - error_log('[finalize-env] failed to write .env'); + $lastError = error_get_last(); + addLog(sprintf( + '[finalize-env] file_put_contents FAILED: path=%s, last_error=%s', + $envPath, + $lastError['message'] ?? 'unknown', + )); return; } - @chmod($envPath, 0600); + // 머지 후 권한·소유자·그룹 상태를 명시 기록 — 운영자 사후 검증용 + $envPermsAfter = fileperms($envPath) & 0777; + $envOwnerAfter = @fileowner($envPath); + $envGroupAfter = @filegroup($envPath); + + $currentEuid = function_exists('posix_geteuid') ? posix_geteuid() : null; + $currentEuser = ($currentEuid !== null && function_exists('posix_getpwuid')) + ? (posix_getpwuid($currentEuid)['name'] ?? (string) $currentEuid) + : 'unknown'; + + addLog(sprintf( + '[finalize-env] .env 머지 완료 (chmod 시도 안 함, 기존 권한 보존): path=%s, perms=%s, owner_uid=%s, group_gid=%s, process_euser=%s', + $envPath, + decoct($envPermsAfter), + $envOwnerAfter === false ? 'unknown' : (string) $envOwnerAfter, + $envGroupAfter === false ? 'unknown' : (string) $envGroupAfter, + $currentEuser, + )); // runtime.php 삭제 — Provider 가 다음 부팅부터 no-op - deleteInstallerRuntime(); + if (! deleteInstallerRuntime()) { + addLog(sprintf( + '[finalize-env] deleteInstallerRuntime FAILED: path=%s, last_error=%s. ' + .'runtime.php 잔존 — InstallerRuntimeServiceProvider 폴백 동작 유지되나 평문 자격증명 보안 우려.', + INSTALLER_RUNTIME_PATH, + (error_get_last()['message'] ?? 'unknown'), + )); + } else { + addLog('[finalize-env] runtime.php 삭제 완료'); + } // state.json 삭제 — setInstallationCompleteSSE 가 본 단계로 위임함 // (finalize 가 generateEnvContent() 호출 시 state.config 가 필요했기 때문) if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) { $stateFilePath = BASE_PATH . '/storage/installer-state.json'; if (is_file($stateFilePath)) { - @unlink($stateFilePath); + if (@unlink($stateFilePath) === false) { + addLog(sprintf( + '[finalize-env] state.json unlink FAILED: path=%s, last_error=%s', + $stateFilePath, + (error_get_last()['message'] ?? 'unknown'), + )); + } else { + addLog('[finalize-env] state.json 삭제 완료'); + } } } } catch (\Throwable $e) { // 예외 시 runtime.php 보존 → Provider 가 계속 config 주입 → 앱 정상 동작. - error_log('[finalize-env] unexpected exception: ' . $e->getMessage()); + addLog('[finalize-env] unexpected exception: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); } diff --git a/public/install/includes/installer-state.php b/public/install/includes/installer-state.php index aa0b023d..20d8a6f0 100644 --- a/public/install/includes/installer-state.php +++ b/public/install/includes/installer-state.php @@ -42,7 +42,9 @@ function getInstallationState(): array // 파일 읽기 권한 체크 if (!is_readable(STATE_PATH)) { - error_log("State file is not readable: " . STATE_PATH); + $msg = "[installer-state] State file is not readable: " . STATE_PATH; + error_log($msg); + addLog($msg); return $defaultState; } @@ -51,7 +53,9 @@ function getInstallationState(): array // 파일 읽기 실패 시 기본 상태 반환 if ($content === false) { - error_log("Failed to read state file: " . STATE_PATH); + $msg = "[installer-state] Failed to read state file: " . STATE_PATH; + error_log($msg); + addLog($msg); return $defaultState; } @@ -61,7 +65,9 @@ function getInstallationState(): array if (json_last_error() !== JSON_ERROR_NONE) { $contentLen = strlen($content); $preview = substr($content, 0, 200); - error_log("Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview); + $msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview; + error_log($msg); + addLog($msg); return $defaultState; } @@ -79,13 +85,17 @@ function saveInstallationState(array $state): bool // storage 디렉토리 존재 여부 확인 (생성하지 않음) $storageDir = BASE_PATH . '/storage'; if (!is_dir($storageDir)) { - error_log("Storage directory does not exist: {$storageDir}"); + $msg = "[installer-state] Storage directory does not exist: {$storageDir}"; + error_log($msg); + addLog($msg); return false; } // 디렉토리 쓰기 권한 확인 if (!is_writable($storageDir)) { - error_log("Storage directory is not writable: {$storageDir}"); + $msg = "[installer-state] Storage directory is not writable: {$storageDir}"; + error_log($msg); + addLog($msg); return false; } @@ -96,7 +106,9 @@ function saveInstallationState(array $state): bool $content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($content === false) { - error_log("Failed to encode state as JSON: " . json_last_error_msg()); + $msg = "[installer-state] Failed to encode state as JSON: " . json_last_error_msg(); + error_log($msg); + addLog($msg); return false; } @@ -107,7 +119,9 @@ function saveInstallationState(array $state): bool $result = @file_put_contents($tmpPath, $content, LOCK_EX); if ($result === false || $result !== strlen($content)) { - error_log("Failed to write state tmp file: " . $tmpPath); + $msg = "[installer-state] Failed to write state tmp file: " . $tmpPath; + error_log($msg); + addLog($msg); @unlink($tmpPath); return false; } @@ -115,7 +129,9 @@ function saveInstallationState(array $state): bool @chmod($tmpPath, 0664); if (!@rename($tmpPath, STATE_PATH)) { - error_log("Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH); + $msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH; + error_log($msg); + addLog($msg); @unlink($tmpPath); return false; } @@ -379,13 +395,17 @@ function addLogBatch(array $messages): bool if (!is_dir($logDir)) { $created = @mkdir($logDir, 0775, true); if (!$created) { - error_log("Failed to create log directory: {$logDir}"); + $msg = "[addLogBatch] Failed to create log directory: {$logDir}"; + error_log($msg); + addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행 return false; } } if (!is_writable($logDir)) { - error_log("Log directory is not writable: {$logDir}"); + $msg = "[addLogBatch] Log directory is not writable: {$logDir}"; + error_log($msg); + addLog($msg); // 재귀 가드가 차단 return false; } @@ -412,7 +432,9 @@ function addLogBatch(array $messages): bool $handle = @fopen($logFile, 'a'); if ($handle === false) { - error_log("Failed to open log file: {$logFile}"); + $msg = "[addLogBatch] Failed to open log file: {$logFile}"; + error_log($msg); + addLog($msg); // 재귀 가드가 차단 return false; } flock($handle, LOCK_EX); @@ -425,6 +447,36 @@ function addLogBatch(array $messages): bool } function addLog(string $message): bool +{ + // 무한 재귀 가드 — addLog 내부 폴백 경로에서 자기 자신을 다시 호출하면 안 된다. + // installer-state.php / session.php / finalize-env.php 등에서 error_log 와 addLog 를 + // 병행 호출하는 경우, addLog 내부의 인프라 실패 폴백 (storage 쓰기 실패 등) 시 + // 호출자가 다시 addLog 를 부르면 같은 인프라가 다시 실패하여 무한 재귀가 발생할 수 있다. + static $inAddLog = false; + if ($inAddLog) { + // 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료 + error_log("[addLog reentrant] " . $message); + return false; + } + $inAddLog = true; + + try { + return _addLogInternal($message); + } finally { + $inAddLog = false; + } +} + +/** + * addLog 의 실제 구현 — 무한 재귀 가드를 거친 후 호출된다. + * + * 본 함수 안에서는 절대 addLog() 를 호출하지 말 것 (무한 재귀 위험). + * 내부 폴백은 error_log() 만 사용. + * + * @param string $message 기록할 메시지 + * @return bool 성공 여부 + */ +function _addLogInternal(string $message): bool { $logDir = BASE_PATH . '/storage/logs'; $logFile = $logDir . '/installation.log'; @@ -433,14 +485,18 @@ function addLog(string $message): bool if (!is_dir($logDir)) { $created = @mkdir($logDir, 0775, true); if (!$created) { - error_log("Failed to create log directory: {$logDir} (storage 권한 확인 필요)"); + $msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)"; + error_log($msg); + addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안) return false; } } // 로그 디렉토리 쓰기 권한 확인 if (!is_writable($logDir)) { - error_log("Log directory is not writable: {$logDir}"); + $msg = "[addLog] Log directory is not writable: {$logDir}"; + error_log($msg); + addLog($msg); // 재귀 가드가 차단 return false; } @@ -471,7 +527,9 @@ function addLog(string $message): bool // 파일 핸들 열기 (append 모드) $handle = @fopen($logFile, 'a'); if ($handle === false) { - error_log("Failed to open log file: {$logFile}"); + $msg = "[addLog] Failed to open log file: {$logFile}"; + error_log($msg); + addLog($msg); // 재귀 가드가 차단 return false; } @@ -492,7 +550,9 @@ function addLog(string $message): bool clearstatcache(true, $logFile); if ($result === false) { - error_log("Failed to write log file: {$logFile}"); + $msg = "[addLog] Failed to write log file: {$logFile}"; + error_log($msg); + addLog($msg); // 재귀 가드가 차단 return false; } diff --git a/public/install/includes/session.php b/public/install/includes/session.php index b17c432a..37860b14 100644 --- a/public/install/includes/session.php +++ b/public/install/includes/session.php @@ -4,8 +4,15 @@ * 세션 관리 및 CSRF 토큰 처리 * * 그누보드7 웹 인스톨러의 세션 관리와 CSRF 보호 기능을 제공합니다. + * + * installer-state.php 를 require 하여 addLog() 함수를 사용 가능하게 한다. + * index.php 및 일부 API 엔드포인트가 본 파일을 installer-state.php 보다 먼저 + * require 하기 때문에, 본 파일 안에서 addLog 호출이 필요한 경우를 위해 + * 명시적으로 의존성을 선언한다 (이슈 #371). */ +require_once __DIR__ . '/installer-state.php'; + // 세션이 이미 시작되지 않았으면 시작 if (session_status() === PHP_SESSION_NONE) { // 세션 설정 @@ -39,7 +46,9 @@ if (session_status() === PHP_SESSION_NONE) { if (json_last_error() === JSON_ERROR_NONE && isset($state['g7_locale'])) { $detectedLang = $state['g7_locale']; - error_log("[세션 초기화] state.json에서 언어 복원: {$detectedLang}"); + $msg = "[세션 초기화] state.json에서 언어 복원: {$detectedLang}"; + error_log($msg); + addLog($msg); } } } @@ -51,15 +60,21 @@ if (session_status() === PHP_SESSION_NONE) { if ($browserLang !== null) { $detectedLang = $browserLang; - error_log("[세션 초기화] 브라우저에서 언어 감지: {$browserLang}"); + $msg = "[세션 초기화] 브라우저에서 언어 감지: {$browserLang}"; + error_log($msg); + addLog($msg); } } // 3. 최종 기본값 $_SESSION['g7_locale'] = $detectedLang ?? 'ko'; - error_log("[세션 초기화] 최종 설정 언어: {$_SESSION['g7_locale']}"); + $msg = "[세션 초기화] 최종 설정 언어: {$_SESSION['g7_locale']}"; + error_log($msg); + addLog($msg); } else { - error_log("[세션 초기화] g7_locale 이미 존재: {$_SESSION['g7_locale']}"); + $msg = "[세션 초기화] g7_locale 이미 존재: {$_SESSION['g7_locale']}"; + error_log($msg); + addLog($msg); } } diff --git a/public/install/lang/en.php b/public/install/lang/en.php index 2dfd6ae5..bb01c6e2 100644 --- a/public/install/lang/en.php +++ b/public/install/lang/en.php @@ -138,6 +138,13 @@ return [ 'recommendation_env_permission' => 'Set .env file permissions to 644 (chmod 644 .env)', 'recommendation_https' => 'Use HTTPS in production environment', + // .env permission hardening hint (Step 5 completion screen, shown when not 0600) + 'env_hardening_hint_title' => '.env Permission Hardening (Optional)', + 'env_hardening_hint_current_status' => 'Current .env permission is %s (owner: %s). The most secure permission is 0600 (owner read/write only). To apply additional hardening, run the appropriate command below with sudo based on your environment.', + 'env_hardening_hint_option_strict' => 'CLI user = web server user (single-user environment)', + 'env_hardening_hint_option_group' => 'CLI user ≠ web server user (self-hosted server, etc. — group-readable compromise)', + 'env_hardening_hint_note' => 'Verify your environment before running. Applying the wrong permission may prevent the web server from reading .env and cause the site to stop responding.', + // Step 5: Complete 'installation_info' => 'Installation Information', 'installation_complete_message' => 'Gnuboard7 has been successfully installed!', diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php index b7e34025..6e999121 100644 --- a/public/install/lang/ko.php +++ b/public/install/lang/ko.php @@ -138,6 +138,13 @@ return [ 'recommendation_env_permission' => '.env 파일 권한을 644로 설정하세요 (chmod 644 .env)', 'recommendation_https' => '프로덕션 환경에서는 반드시 HTTPS를 사용하세요', + // .env 권한 보안 강화 안내 (Step 5 완료 화면, 0600 미적용 시 조건부 노출) + 'env_hardening_hint_title' => '.env 권한 보안 강화 (선택)', + 'env_hardening_hint_current_status' => '현재 .env 권한은 %s 입니다 (소유자: %s). 가장 안전한 권한은 0600 (소유자만 읽기·쓰기) 이며, 다음 두 명령 중 환경에 맞는 쪽을 sudo 로 실행하여 추가 보안 강화를 적용할 수 있습니다.', + 'env_hardening_hint_option_strict' => '명령줄 사용자 = 웹 서버 사용자 (단일 사용자 환경)', + 'env_hardening_hint_option_group' => '명령줄 사용자 ≠ 웹 서버 사용자 (자체 구축 서버 등 — 그룹 읽기 허용 절충안)', + 'env_hardening_hint_note' => '명령 실행 전 환경을 확인하세요. 잘못된 권한 적용 시 웹 서버가 .env 를 읽지 못해 사이트가 응답하지 않을 수 있습니다.', + // Step 5: 완료 'installation_info' => '설치 정보', 'installation_complete_message' => '그누보드7이 성공적으로 설치되었습니다!', diff --git a/public/install/views/5-installation.php b/public/install/views/5-installation.php index 7ff11cf3..6fce895f 100644 --- a/public/install/views/5-installation.php +++ b/public/install/views/5-installation.php @@ -67,6 +67,55 @@ $config = $state['config'] ?? $_SESSION['install_config'] ?? []; . '' . htmlspecialchars($coreVersion) . '' . '

'; + // .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371) + // finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한 + // (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을 + // 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출. + // + // OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작. + // Windows 만 명령이 무의미하므로 Windows 만 비노출. + $envPath = BASE_PATH . '/.env'; + if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) { + $envPerms = fileperms($envPath) & 0777; + if ($envPerms !== 0600) { + $envOwnerUid = @fileowner($envPath); + $envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid')) + ? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid) + : 'owner'; + $webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid') + ? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data') + : 'www-data'; + + $hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath)); + $hardeningCommandDifferentOwner = sprintf( + 'sudo chgrp %s %s && sudo chmod 640 %s', + htmlspecialchars($webGroupName), + htmlspecialchars($envPath), + htmlspecialchars($envPath) + ); + + $completionExtra .= '
' + . '

' . htmlspecialchars(lang('env_hardening_hint_title')) . '

' + . '

' + . sprintf( + htmlspecialchars(lang('env_hardening_hint_current_status')), + decoct($envPerms), + htmlspecialchars($envOwnerName) + ) + . '

' + . '

' + . htmlspecialchars(lang('env_hardening_hint_option_strict')) + . '

' + . '
' . $hardeningCommandSameOwner . '
' + . '

' + . htmlspecialchars(lang('env_hardening_hint_option_group')) + . '

' + . '
' . $hardeningCommandDifferentOwner . '
' + . '

' . htmlspecialchars(lang('env_hardening_hint_note')) . '

' + . '
'; + } + } + echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra); ?> diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index d29d2d1e..d76fc51b 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -4,6 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.0-beta.7] - 2026-05-15 + +### Changed + +- 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 보강합니다. (#30 @yks118 님께서 건의해주셨습니다.) +- 위 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가하여 코어 `INSTALL.md` 의 "업그레이드" 섹션으로 연결합니다. + ## [1.0.0-beta.6] - 2026-05-14 ### Fixed diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index 0548f5a7..9e4ae54c 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1834,7 +1834,12 @@ "manual_update_title": "Manual Update (No ZIP Extension)", "manual_update_desc": "If PHP zip extension or unzip command is not available on your server, you can download the release ZIP from GitHub and update manually.", "manual_update_step1": "# 1. Download ZIP from GitHub releases page and extract on server", - "manual_update_step2": "# 2. Run update with the extracted path" + "manual_update_step2": "# 2. Run update with the extracted path", + "update_guide_cmd_comment": "# Run with administrator privileges (Linux/macOS)", + "update_guide_cmd": "$ sudo php artisan core:update", + "manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted", + "upgrade_guide_link_desc": "See the upgrade guide for permission notes and per-version paths.", + "upgrade_guide_link_label": "Open the full upgrade guide" }, "modals": { "password_confirm_title": "Password Confirmation", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 4074c5c3..deb8c14a 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1838,7 +1838,12 @@ "manual_update_title": "수동 업데이트 (ZIP 확장 미설치 환경)", "manual_update_desc": "서버에 PHP zip 확장이나 unzip 명령어가 없는 경우, GitHub에서 릴리스 ZIP을 직접 다운로드하여 업데이트할 수 있습니다.", "manual_update_step1": "# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제", - "manual_update_step2": "# 2. 압축 해제된 경로를 지정하여 업데이트 실행" + "manual_update_step2": "# 2. 압축 해제된 경로를 지정하여 업데이트 실행", + "update_guide_cmd_comment": "# 관리자 권한으로 실행 권장 (Linux/macOS)", + "update_guide_cmd": "$ sudo php artisan core:update", + "manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted", + "upgrade_guide_link_desc": "운영 환경별 권한/사용자군별 자세한 절차는 업그레이드 가이드를 참고하세요.", + "upgrade_guide_link_label": "전체 업그레이드 가이드 보기" }, "modals": { "password_confirm_title": "비밀번호 확인", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json index 188e5806..21a3fe81 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json @@ -38,9 +38,10 @@ { "type": "basic", "name": "Div", - "props": { "className": "bg-gray-900 dark:bg-gray-950 rounded-lg p-4" }, + "props": { "className": "bg-gray-900 dark:bg-gray-950 rounded-lg p-4 space-y-1" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$ php artisan core:update" } + { "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.update_guide_cmd_comment" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$t:admin.settings.info.update_guide_cmd" } ] }, { @@ -49,6 +50,33 @@ "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, "text": "$t:admin.settings.info.update_guide_note" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-t border-gray-200 dark:border-gray-700 pt-4 mt-2" }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { "className": "text-sm text-gray-600 dark:text-gray-400 mb-2" }, + "text": "$t:admin.settings.info.upgrade_guide_link_desc" + }, + { + "type": "basic", + "name": "A", + "props": { + "href": "https://github.com/gnuboard/g7/blob/main/INSTALL.md#%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C", + "target": "_blank", + "rel": "noopener noreferrer", + "className": "inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline" + }, + "children": [ + { "type": "basic", "name": "Span", "text": "$t:admin.settings.info.upgrade_guide_link_label" }, + { "type": "basic", "name": "Icon", "props": { "name": "arrow-up-right-from-square", "className": "w-3 h-3" } } + ] + } + ] + }, { "type": "basic", "name": "Div", @@ -73,7 +101,7 @@ "children": [ { "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.manual_update_step1" }, { "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.manual_update_step2" }, - { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$ php artisan core:update --source=/path/to/extracted" } + { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$t:admin.settings.info.manual_update_cmd" } ] } ] diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json index b0d8d9e5..442cf989 100644 --- a/templates/_bundled/sirsoft-admin_basic/package-lock.json +++ b/templates/_bundled/sirsoft-admin_basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "sirsoft-admin_basic", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-admin_basic", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/templates/_bundled/sirsoft-admin_basic/package.json b/templates/_bundled/sirsoft-admin_basic/package.json index 790e5d9a..036a0277 100644 --- a/templates/_bundled/sirsoft-admin_basic/package.json +++ b/templates/_bundled/sirsoft-admin_basic/package.json @@ -1,6 +1,6 @@ { "name": "sirsoft-admin_basic", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "description": "Gnuboard7 Basic Admin Template Components", "type": "module", "main": "dist/components.js", diff --git a/templates/_bundled/sirsoft-admin_basic/template.json b/templates/_bundled/sirsoft-admin_basic/template.json index 0918eb68..4537c685 100644 --- a/templates/_bundled/sirsoft-admin_basic/template.json +++ b/templates/_bundled/sirsoft-admin_basic/template.json @@ -5,7 +5,7 @@ "ko": "Admin Basic", "en": "Admin Basic" }, - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "license": "MIT", "description": { "ko": "그누보드7 기본 관리자 템플릿", diff --git a/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php b/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php new file mode 100644 index 00000000..a8d70e5d --- /dev/null +++ b/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php @@ -0,0 +1,545 @@ + axis 12 가 putenv / $_ENV / $_SERVER 를 + * 덮어쓰므로 후속 테스트 / 다음 테스트 파일의 DB 자격증명이 오염되지 + * 않도록 tearDown 에서 복원할 ENV 키별 원본 스냅샷. + */ + private array $originalEnvSnapshot = []; + + private const ENV_KEYS_FOR_SNAPSHOT = [ + 'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD', + 'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD', + 'DB_PREFIX', 'APP_KEY', + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->runtimePath = base_path('storage/installer/runtime.php'); + $this->envPath = base_path('.env'); + $this->envExamplePath = base_path('.env.example'); + $this->stateJsonPath = base_path('storage/installer-state.json'); + + // 실제 운영 .env 와 state.json 보존 + if (is_file($this->envPath)) { + $this->originalEnvExisted = true; + $this->originalEnvContent = file_get_contents($this->envPath); + } + if (is_file($this->stateJsonPath)) { + $this->originalStateExisted = true; + $this->originalStateContent = file_get_contents($this->stateJsonPath); + } + + $this->originalRuntimeDirExisted = is_dir(dirname($this->runtimePath)); + + // process ENV 원본 스냅샷 — axis 12 가 putenv() 로 자격증명을 갱신하므로 + // tearDown 에서 정확히 원본 상태로 복원 (다음 테스트 / 파일 오염 차단) + foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) { + $value = getenv($key); + $this->originalEnvSnapshot[$key] = $value === false ? null : $value; + } + + // installer-state.php 등의 DELETE_INSTALLER_AFTER_COMPLETE 상수가 다른 테스트로 + // 이미 정의되어 있을 수 있다. 본 테스트는 그 상수를 신뢰하지 않고 명시 시나리오마다 + // 자체 시그널을 사용하므로 추가 정의 불필요. + + // DataMigration 은 autoload 대상이 아님 — 동적 require + require_once base_path('upgrades/data/7.0.0-beta.7/migrations/01_FinalizeOrphanedInstallerRuntime.php'); + + $this->cleanupArtifacts(); + } + + protected function tearDown(): void + { + $this->cleanupArtifacts(); + + // .env 복원 + if ($this->originalEnvExisted && $this->originalEnvContent !== null) { + file_put_contents($this->envPath, $this->originalEnvContent); + } elseif (! $this->originalEnvExisted && is_file($this->envPath)) { + @unlink($this->envPath); + } + + // state.json 복원 + if ($this->originalStateExisted && $this->originalStateContent !== null) { + file_put_contents($this->stateJsonPath, $this->originalStateContent); + } elseif (! $this->originalStateExisted && is_file($this->stateJsonPath)) { + @unlink($this->stateJsonPath); + } + + // 본 테스트가 생성한 runtime 디렉토리 정리 (원래 없었을 때만) + if (! $this->originalRuntimeDirExisted && is_dir(dirname($this->runtimePath))) { + @rmdir(dirname($this->runtimePath)); + } + + // process ENV 복원 — axis 12 가 putenv() 로 자격증명을 갱신했어도 + // 다음 테스트 / 파일이 정확히 setUp 직전 상태에서 시작하도록 보장. + foreach ($this->originalEnvSnapshot as $key => $value) { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + + parent::tearDown(); + } + + private function cleanupArtifacts(): void + { + if (is_file($this->runtimePath)) { + @unlink($this->runtimePath); + } + } + + private function context(): UpgradeContext + { + return new UpgradeContext('7.0.0-beta.6', '7.0.0-beta.7', '7.0.0-beta.7'); + } + + private function writeRuntime(array $data): void + { + $dir = dirname($this->runtimePath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + $php = "runtimePath, $php); + } + + private function sampleRuntime(): array + { + return [ + 'db' => [ + 'write' => [ + 'host' => '127.0.0.1', + 'port' => '3306', + 'database' => 'g7_test_db', + 'username' => 'test_user', + 'password' => 'test_pass_42', + ], + 'prefix' => 'g7_', + ], + 'app' => [ + 'key' => 'base64:'.base64_encode(random_bytes(32)), + ], + 'created_at' => date('c'), + ]; + } + + private function writeEnv(string $content): void + { + file_put_contents($this->envPath, $content); + } + + // ----------------------------------------------------------------------- + // axis 1: 정상 finalize — .env 머지 + runtime 삭제 + state.json 삭제 + // ----------------------------------------------------------------------- + public function test_finalizes_orphaned_runtime_and_merges_env(): void + { + $runtime = $this->sampleRuntime(); + $this->writeRuntime($runtime); + $this->writeEnv("APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\nDB_WRITE_PASSWORD=\nAPP_KEY=\n"); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $this->assertFileDoesNotExist($this->runtimePath, 'runtime.php 가 삭제되어야 함'); + $envContent = file_get_contents($this->envPath); + $this->assertStringContainsString('DB_WRITE_DATABASE=g7_test_db', $envContent); + $this->assertStringContainsString('DB_WRITE_USERNAME=test_user', $envContent); + $this->assertStringContainsString('DB_WRITE_PASSWORD="test_pass_42"', $envContent); + $this->assertStringContainsString('APP_KEY=base64:', $envContent); + $this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent); + } + + // ----------------------------------------------------------------------- + // axis 2: state.json 부재 시에도 멱등 (.env 머지 + runtime 삭제) + // ----------------------------------------------------------------------- + public function test_finalizes_when_state_json_missing(): void + { + $this->writeRuntime($this->sampleRuntime()); + $this->writeEnv("APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\n"); + + if (is_file($this->stateJsonPath)) { + @unlink($this->stateJsonPath); + } + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $this->assertFileDoesNotExist($this->runtimePath); + $envContent = file_get_contents($this->envPath); + $this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent); + // state.json 은 부재 상태 유지 — 비정상 종료 아님 + $this->assertFileDoesNotExist($this->stateJsonPath); + } + + // ----------------------------------------------------------------------- + // axis 3: runtime.php 부재 → no-op + // ----------------------------------------------------------------------- + public function test_noop_when_runtime_absent(): void + { + if (is_file($this->runtimePath)) { + @unlink($this->runtimePath); + } + $originalEnv = "APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\n"; + $this->writeEnv($originalEnv); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + // .env 가 변경되지 않아야 함 — 멱등 no-op + $this->assertSame($originalEnv, file_get_contents($this->envPath)); + } + + // ----------------------------------------------------------------------- + // axis 4: .env 의 INSTALLER_COMPLETED=true + runtime 잔존 → 보존 + warn + // ----------------------------------------------------------------------- + public function test_preserves_runtime_when_env_marked_completed(): void + { + $runtime = $this->sampleRuntime(); + $this->writeRuntime($runtime); + $envContent = "APP_ENV=production\nINSTALLER_COMPLETED=true\n"; + $this->writeEnv($envContent); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + // runtime 보존 — 자동 삭제 금지 + $this->assertFileExists($this->runtimePath); + // .env 변경 없음 + $this->assertSame($envContent, file_get_contents($this->envPath)); + } + + // ----------------------------------------------------------------------- + // axis 5: .env 부재 + .env.example 만 존재 → .env.example 기반 신규 생성 + // ----------------------------------------------------------------------- + public function test_creates_env_from_example_when_env_absent(): void + { + if (! is_file($this->envExamplePath)) { + $this->markTestSkipped('.env.example 이 프로젝트 루트에 없음 — 테스트 건너뜀'); + } + + $this->writeRuntime($this->sampleRuntime()); + + if (is_file($this->envPath)) { + @unlink($this->envPath); + } + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $this->assertFileExists($this->envPath, '.env 가 .env.example 기반으로 생성되어야 함'); + $envContent = file_get_contents($this->envPath); + $this->assertStringContainsString('DB_WRITE_DATABASE=g7_test_db', $envContent); + $this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent); + $this->assertFileDoesNotExist($this->runtimePath); + } + + // ----------------------------------------------------------------------- + // axis 6: runtime.php 가 배열 아닌 형식 → 보존, skip + // ----------------------------------------------------------------------- + public function test_preserves_runtime_when_format_invalid(): void + { + $dir = dirname($this->runtimePath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + // 배열을 반환하지 않는 PHP — null 반환 + file_put_contents($this->runtimePath, "writeEnv($originalEnv); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $this->assertFileExists($this->runtimePath, '형식 불일치 runtime.php 는 보존되어야 함'); + $this->assertSame($originalEnv, file_get_contents($this->envPath)); + } + + // ----------------------------------------------------------------------- + // axis 7: .env 의 truthy 변형 ("1", quoted "true") 도 보존 트리거 + // ----------------------------------------------------------------------- + public function test_preserves_runtime_for_truthy_variants(): void + { + $runtime = $this->sampleRuntime(); + + $variants = [ + 'INSTALLER_COMPLETED=1', + 'INSTALLER_COMPLETED="true"', + 'INSTALLER_COMPLETED=yes', + ]; + + foreach ($variants as $line) { + $this->writeRuntime($runtime); + $envContent = "APP_ENV=production\n{$line}\n"; + $this->writeEnv($envContent); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $this->assertFileExists( + $this->runtimePath, + sprintf('변형 %s 에서 runtime.php 가 보존되어야 함', $line) + ); + $this->assertSame( + $envContent, + file_get_contents($this->envPath), + sprintf('변형 %s 에서 .env 가 변경되지 않아야 함', $line) + ); + + // 다음 반복을 위한 정리 + @unlink($this->runtimePath); + } + } + + // ----------------------------------------------------------------------- + // axis 8: 기존 .env 의 권한/그룹 보존 — CLI 사용자와 PHP-FPM 사용자가 다른 환경 + // (자체 구축 서버: jjh:www-data 0640) + // ----------------------------------------------------------------------- + public function test_preserves_env_mode_when_env_existed(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원 — Linux 환경에서만 검증'); + } + + $this->writeRuntime($this->sampleRuntime()); + $this->writeEnv("APP_ENV=production\nAPP_KEY=\n"); + // 자체 구축 서버 시뮬레이션 — 그룹 읽기 허용 (PHP-FPM 이 그룹 멤버로 읽음) + chmod($this->envPath, 0640); + + $beforeMode = fileperms($this->envPath) & 0777; + $this->assertSame(0640, $beforeMode, 'precondition: .env 권한 0640'); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $afterMode = fileperms($this->envPath) & 0777; + $this->assertSame( + $beforeMode, + $afterMode, + sprintf('기존 .env 권한(%s) 이 머지 후에도 보존되어야 함 — PHP-FPM 이 계속 읽을 수 있도록', decoct($beforeMode)) + ); + $this->assertFileDoesNotExist($this->runtimePath); + } + + // ----------------------------------------------------------------------- + // axis 9: 0600 으로 잠긴 .env 도 그대로 보존 — 본 회귀 (#371) 의 원인은 우리가 + // 0600 으로 강제 다운그레이드 한 것이었음. 운영자가 의도해서 0600 을 둔 + // 경우 (단일 사용자 환경) 도 같은 정책 — 기존 권한이 SSoT + // ----------------------------------------------------------------------- + public function test_preserves_strict_env_mode_unchanged(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원'); + } + + $this->writeRuntime($this->sampleRuntime()); + $this->writeEnv("APP_ENV=production\n"); + chmod($this->envPath, 0600); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $this->assertSame( + 0600, + fileperms($this->envPath) & 0777, + '운영자가 의도한 0600 권한도 그대로 보존' + ); + } + + // ----------------------------------------------------------------------- + // axis 10: .env 가 새로 생성될 때 default 권한이 0640 — 0600 으로 강제 금지 + // (#371 회귀 가드 — PHP-FPM 사용자가 그룹으로만 묶인 환경 보호) + // ----------------------------------------------------------------------- + public function test_new_env_uses_group_readable_default_permission(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원'); + } + if (! is_file($this->envExamplePath)) { + $this->markTestSkipped('.env.example 부재'); + } + + $this->writeRuntime($this->sampleRuntime()); + if (is_file($this->envPath)) { + @unlink($this->envPath); + } + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $mode = fileperms($this->envPath) & 0777; + $this->assertSame( + 0640, + $mode, + sprintf( + '.env 신규 생성 시 default 권한은 0640 이어야 함 (PHP-FPM 그룹 읽기 허용). 실제: %s', + decoct($mode) + ) + ); + } + + // ----------------------------------------------------------------------- + // axis 11: bootstrap/cache/ 의 그룹을 신규 .env 에 계승 — 자체 구축 서버의 + // www-data 그룹 자동 식별. 단일 사용자 환경(카페24)에서는 동일 그룹이 + // 유지되므로 회귀 없음 + // ----------------------------------------------------------------------- + public function test_new_env_inherits_group_from_bootstrap_cache(): void + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원'); + } + if (! is_file($this->envExamplePath)) { + $this->markTestSkipped('.env.example 부재'); + } + $bootstrapCache = base_path('bootstrap/cache'); + if (! is_dir($bootstrapCache)) { + $this->markTestSkipped('bootstrap/cache 디렉토리 부재'); + } + + $expectedGid = filegroup($bootstrapCache); + if ($expectedGid === false) { + $this->markTestSkipped('bootstrap/cache filegroup 조회 실패'); + } + + $this->writeRuntime($this->sampleRuntime()); + if (is_file($this->envPath)) { + @unlink($this->envPath); + } + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + $actualGid = filegroup($this->envPath); + // chgrp 가 실패할 수 있는 환경(권한 부족)에서는 그룹이 호출 사용자의 default + // 그룹으로 떨어질 수 있다. 실패해도 머지 자체는 성공해야 하므로 .env 존재만 단언. + $this->assertFileExists($this->envPath); + + // 같은 사용자가 bootstrap/cache 의 그룹 멤버인 경우 chgrp 가 성공해 일치해야 함. + // 그렇지 않으면 운영자 수동 보정 안내가 로그에 남는 것까지가 본 axis 의 기대. + if ($actualGid !== $expectedGid) { + $this->markTestSkipped(sprintf( + 'chgrp 실패 환경 (실행자가 그룹 %d 멤버 아님) — silent fallback 으로 통과', + $expectedGid + )); + } + $this->assertSame($expectedGid, $actualGid, 'bootstrap/cache 그룹이 .env 에 계승되어야 함'); + } + + // ----------------------------------------------------------------------- + // axis 12: 머지 후 부모 프로세스의 process ENV (getenv() / $_ENV / $_SERVER) + // 도 신규 자격증명으로 갱신되어야 함. + // + // 배경 (이슈 #371 후속 회귀): + // beta.4~6 손상 환경에서는 .env 가 .env.example 그대로 (DB_WRITE_USERNAME=root, + // DB_WRITE_PASSWORD= 등) 이고 자격증명은 runtime.php 에 잔존. 부모 프로세스 부팅 + // 시점에 Dotenv 가 stale .env 의 ENV (DB_WRITE_USERNAME=root) 를 process ENV + // 에 적재. 마이그레이션이 .env 머지 + runtime.php 삭제 를 수행해도 process ENV + // 는 그대로. 이후 번들 일괄 업데이트 spawn 자식 (BundledExtensionUpdatePrompt 의 + // proc_open 5번째 인자 $env = array_merge(getenv(), $_ENV)) 이 부모 stale ENV + // 를 그대로 상속받고, Dotenv::createImmutable() 가 이미 채워진 ENV 를 덮어쓰지 + // 않으므로 자식이 stale root/패스워드YES 로 DB 연결 시도 → Access denied. + // + // 본 axis 는 마이그레이션 종료 시점에 getenv()/$_ENV/$_SERVER 가 머지된 신규 + // 자격증명을 반영하는지 검증한다. 한시적 보정 — beta.8 이후 환경에서는 .env 가 + // 이미 정상이므로 트리거 안 됨. + // ----------------------------------------------------------------------- + public function test_refreshes_process_env_with_merged_credentials(): void + { + // .env.example 잔존 상태 시뮬레이션 (DB_WRITE_USERNAME=root, DB_WRITE_PASSWORD=) + $this->writeEnv( + "APP_ENV=production\n" + ."DB_WRITE_USERNAME=root\n" + ."DB_WRITE_PASSWORD=\n" + ."DB_WRITE_DATABASE=laravel\n" + ."DB_READ_USERNAME=root\n" + ."DB_READ_PASSWORD=\n" + ."DB_READ_DATABASE=laravel\n" + ); + // 부모 프로세스 ENV 가 stale .env 값으로 채워진 상태 시뮬레이션 + putenv('DB_WRITE_USERNAME=root'); + putenv('DB_WRITE_PASSWORD='); + putenv('DB_WRITE_DATABASE=laravel'); + putenv('DB_READ_USERNAME=root'); + putenv('DB_READ_PASSWORD='); + putenv('DB_READ_DATABASE=laravel'); + $_ENV['DB_WRITE_USERNAME'] = 'root'; + $_ENV['DB_WRITE_PASSWORD'] = ''; + $_ENV['DB_WRITE_DATABASE'] = 'laravel'; + $_ENV['DB_READ_USERNAME'] = 'root'; + $_ENV['DB_READ_PASSWORD'] = ''; + $_ENV['DB_READ_DATABASE'] = 'laravel'; + + $this->writeRuntime($this->sampleRuntime()); + + (new FinalizeOrphanedInstallerRuntime)->run($this->context()); + + // 디스크 .env 머지는 기존 axis 1 에서 검증 — 본 axis 는 process ENV 검증 + $this->assertSame( + 'test_user', + getenv('DB_WRITE_USERNAME'), + 'getenv(DB_WRITE_USERNAME) 이 runtime 의 신규 자격증명으로 갱신되어야 함' + ); + $this->assertSame( + 'test_pass_42', + getenv('DB_WRITE_PASSWORD'), + 'getenv(DB_WRITE_PASSWORD) 이 runtime 의 신규 비밀번호로 갱신되어야 함' + ); + $this->assertSame( + 'g7_test_db', + getenv('DB_WRITE_DATABASE'), + 'getenv(DB_WRITE_DATABASE) 이 runtime 의 신규 DB 이름으로 갱신되어야 함' + ); + $this->assertSame( + 'test_user', + $_ENV['DB_WRITE_USERNAME'] ?? null, + '$_ENV[DB_WRITE_USERNAME] 이 갱신되어야 함 — proc_open ENV 합집합 전파용' + ); + $this->assertSame( + 'test_pass_42', + $_ENV['DB_WRITE_PASSWORD'] ?? null, + '$_ENV[DB_WRITE_PASSWORD] 도 함께 갱신' + ); + // READ 도 동일 정책 — runtime.read 미지정 시 write 값으로 동기화 + $this->assertSame('test_user', getenv('DB_READ_USERNAME')); + $this->assertSame('test_pass_42', getenv('DB_READ_PASSWORD')); + } +} diff --git a/tests/Unit/Extension/UpgradeStepsTest.php b/tests/Unit/Extension/UpgradeStepsTest.php index a9098c51..ee39396b 100644 --- a/tests/Unit/Extension/UpgradeStepsTest.php +++ b/tests/Unit/Extension/UpgradeStepsTest.php @@ -71,6 +71,9 @@ class UpgradeStepsTest extends TestCase '2.0.0' => $step_2_0, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); @@ -111,6 +114,9 @@ class UpgradeStepsTest extends TestCase '1.2.0' => $step_1_2, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); @@ -139,6 +145,9 @@ class UpgradeStepsTest extends TestCase '1.1.0' => $callableStep, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); @@ -166,6 +175,9 @@ class UpgradeStepsTest extends TestCase '1.1.0' => $failingStep, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); @@ -239,6 +251,9 @@ class UpgradeStepsTest extends TestCase '1.1.0' => $otherStep, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); @@ -270,6 +285,9 @@ class UpgradeStepsTest extends TestCase '1.0.0-beta.2' => $step_beta2, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); @@ -307,6 +325,9 @@ class UpgradeStepsTest extends TestCase '1.2.0' => $step_1_2, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); @@ -411,6 +432,9 @@ class UpgradeStepsTest extends TestCase '7.0.0-beta.2' => $step_beta2, ]); $module->shouldReceive('getIdentifier')->andReturn('test-module'); + // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347) + $module->shouldReceive('getRequiredCoreVersion')->andReturn(null); + $module->shouldReceive('getVersion')->andReturn('1.0.0'); $manager = $this->createModuleManagerWithMocks(); $method = new \ReflectionMethod($manager, 'runUpgradeSteps'); diff --git a/tests/Unit/Installer/InstallerGuardTest.php b/tests/Unit/Installer/InstallerGuardTest.php index 996d47f1..3481523f 100644 --- a/tests/Unit/Installer/InstallerGuardTest.php +++ b/tests/Unit/Installer/InstallerGuardTest.php @@ -57,8 +57,14 @@ class InstallerGuardTest extends TestCase private function cleanup(): void { - @unlink(BASE_PATH . '/storage/app/g7_installed'); - @unlink(BASE_PATH . '/.env'); + $lock = BASE_PATH . '/storage/app/g7_installed'; + if (is_file($lock)) { + @unlink($lock); + } + $env = BASE_PATH . '/.env'; + if (is_file($env)) { + @unlink($env); + } } public function test_no_signals_means_not_completed(): void @@ -115,4 +121,65 @@ class InstallerGuardTest extends TestCase $this->assertTrue(installer_is_completed()); } + + // ------------------------------------------------------------------------ + // finalize 전용 가드 — installer_finalize_is_completed() + // + // 일반 가드와 달리 g7_installed 락 파일은 차단 사유에서 제외되어야 한다 + // (complete_flag task 가 락 파일을 먼저 생성한 직후 finalize 가 호출되는 + // 설계상의 호출 순서 때문 — 이슈 #371 자가 차단 회귀 가드). + // ------------------------------------------------------------------------ + + public function test_finalize_guard_passes_with_lock_only(): void + { + // 정상 1회차 finalize 시나리오: 락 파일은 존재하나 .env 머지 아직 안 됨 + file_put_contents(BASE_PATH . '/storage/app/g7_installed', ''); + + $this->assertFalse( + installer_finalize_is_completed(), + 'g7_installed 락 파일 단독 존재 시 finalize 차단되면 자가 차단 회귀 (이슈 #371)' + ); + } + + public function test_finalize_guard_passes_with_no_env_file(): void + { + // .env 자체가 없는 신규 설치 시나리오 + $this->assertFalse(installer_finalize_is_completed()); + } + + public function test_finalize_guard_blocks_when_env_completed_true(): void + { + // 이미 finalize 된 상태 — 멱등 차단 + file_put_contents(BASE_PATH . '/.env', "APP_ENV=local\nINSTALLER_COMPLETED=true\n"); + + $this->assertTrue(installer_finalize_is_completed()); + } + + public function test_finalize_guard_blocks_with_lock_and_env_completed(): void + { + // 락 + .env true 동시 존재 → 차단 (멱등) + file_put_contents(BASE_PATH . '/storage/app/g7_installed', ''); + file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=true' . "\n"); + + $this->assertTrue(installer_finalize_is_completed()); + } + + public function test_finalize_guard_passes_when_env_completed_false(): void + { + file_put_contents(BASE_PATH . '/.env', "INSTALLER_COMPLETED=false\n"); + + $this->assertFalse(installer_finalize_is_completed()); + } + + public function test_finalize_guard_accepts_quoted_and_alt_truthy_values(): void + { + file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED="true"' . "\n"); + $this->assertTrue(installer_finalize_is_completed()); + + file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=1' . "\n"); + $this->assertTrue(installer_finalize_is_completed()); + + file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=yes' . "\n"); + $this->assertTrue(installer_finalize_is_completed()); + } } diff --git a/tests/Unit/Installer/InstallerSecurityHardeningTest.php b/tests/Unit/Installer/InstallerSecurityHardeningTest.php index 6363b822..24787d10 100644 --- a/tests/Unit/Installer/InstallerSecurityHardeningTest.php +++ b/tests/Unit/Installer/InstallerSecurityHardeningTest.php @@ -73,9 +73,13 @@ class InstallerSecurityHardeningTest extends TestCase */ protected function tearDown(): void { - @unlink(STATE_PATH); + if (is_file(STATE_PATH)) { + @unlink(STATE_PATH); + } $projectStateFile = dirname(__DIR__, 3) . '/storage/installer-state.json'; - @unlink($projectStateFile); + if (is_file($projectStateFile)) { + @unlink($projectStateFile); + } parent::tearDown(); } @@ -366,7 +370,7 @@ class InstallerSecurityHardeningTest extends TestCase '/opt/php/bin/php', 'C:/php/php.exe', '/nonexistent/path', - // Windows 절대경로 (백슬래시 포함) — 회귀 가드: PO 환경 (Windows 빌트인 서버) + // Windows 절대경로 (백슬래시 포함) — 회귀 가드: Windows 빌트인 서버 환경 'C:\\laragon\\bin\\php\\php-8.3.26-Win32-vs16-x64\\php.exe', 'C:\\php\\php.exe', 'D:\\xampp\\php\\php.exe', diff --git a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php index 967916cb..bfc52770 100644 --- a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php +++ b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php @@ -20,16 +20,44 @@ class InstallerRuntimeServiceProviderTest extends TestCase private ?string $originalEnv = null; + private string $envPath; + + private ?string $originalEnvContent = null; + + private bool $originalEnvExisted = false; + + /** @var array */ + private array $originalEnvSnapshot = []; + + private const ENV_KEYS_FOR_SNAPSHOT = [ + 'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD', + 'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD', + 'DB_PREFIX', 'APP_KEY', + ]; + protected function setUp(): void { parent::setUp(); $this->runtimePath = base_path('storage/installer/runtime.php'); + $this->envPath = base_path('.env'); // 테스트 시작 시 runtime.php 정리 (이전 테스트의 잔재 제거) if (is_file($this->runtimePath)) { @unlink($this->runtimePath); } + + // .env 원본 보존 + if (is_file($this->envPath)) { + $this->originalEnvExisted = true; + $this->originalEnvContent = file_get_contents($this->envPath); + } + + // process ENV 스냅샷 (stale ENV 보정 테스트가 갱신하므로 다음 테스트 격리) + foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) { + $value = getenv($key); + $this->originalEnvSnapshot[$key] = $value === false ? null : $value; + } } protected function tearDown(): void @@ -49,6 +77,25 @@ class InstallerRuntimeServiceProviderTest extends TestCase $this->originalEnv = null; } + // .env 복원 + if ($this->originalEnvExisted && $this->originalEnvContent !== null) { + file_put_contents($this->envPath, $this->originalEnvContent); + } elseif (! $this->originalEnvExisted && is_file($this->envPath)) { + @unlink($this->envPath); + } + + // process ENV 복원 + foreach ($this->originalEnvSnapshot as $key => $value) { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + parent::tearDown(); } @@ -221,6 +268,161 @@ class InstallerRuntimeServiceProviderTest extends TestCase $this->assertSame($originalKey, Config::get('app.key')); } + // ----------------------------------------------------------------------- + // beta.7 한시 보정 — beta.4~6 finalize 결함 환경에서 .env 머지 후 spawn 자식의 + // stale process ENV (DB_WRITE_USERNAME=root 등) 회귀 차단. + // + // 트리거 조건 (모두 만족 시에만 보정): + // 1. runtime.php 부재 (이미 finalize 완료된 상태) + // 2. .env 존재 + 정합 자격증명 보유 (DB_WRITE_USERNAME 이 root 가 아니고 비어있지 않음) + // 3. process ENV 의 DB_WRITE_USERNAME 이 'root' 또는 빈 값 (stale 시그니처) + // 4. .env 의 INSTALLER_COMPLETED 가 truthy (finalize 성공 신호) + // + // 위 4개를 모두 만족하면 .env 의 DB_* / APP_KEY 를 process ENV (getenv/$_ENV/$_SERVER) + // 에 적용. 정상 환경에서는 #3 (stale 시그니처) 가 false 라 트리거 안 됨. + // ----------------------------------------------------------------------- + + public function test_recovers_stale_process_env_when_disk_env_is_healthy(): void + { + $this->withProductionEnv(); + + // 디스크 .env 는 정합 자격증명 + INSTALLER_COMPLETED (finalize 완료 신호) + file_put_contents($this->envPath, implode("\n", [ + 'APP_ENV=production', + 'DB_WRITE_USERNAME=g7_user', + 'DB_WRITE_PASSWORD="real_pw"', + 'DB_WRITE_DATABASE=g7', + 'DB_READ_USERNAME=g7_user', + 'DB_READ_PASSWORD="real_pw"', + 'DB_READ_DATABASE=g7', + 'INSTALLER_COMPLETED=true', + '', + ])); + + // process ENV 는 stale (Dotenv 가 부팅 시 .env.example fallback 을 적재한 상태) + putenv('DB_WRITE_USERNAME=root'); + putenv('DB_WRITE_PASSWORD='); + putenv('DB_READ_USERNAME=root'); + putenv('DB_READ_PASSWORD='); + $_ENV['DB_WRITE_USERNAME'] = 'root'; + $_ENV['DB_WRITE_PASSWORD'] = ''; + $_ENV['DB_READ_USERNAME'] = 'root'; + $_ENV['DB_READ_PASSWORD'] = ''; + // $_SERVER 도 stale — bootstrap/app.php 의 Env::disablePutenv() 환경에서는 + // Dotenv 가 $_ENV/$_SERVER 만 채우므로 셋 다 stale 인 실서버 케이스 재현 + $_SERVER['DB_WRITE_USERNAME'] = 'root'; + $_SERVER['DB_WRITE_PASSWORD'] = ''; + $_SERVER['DB_READ_USERNAME'] = 'root'; + $_SERVER['DB_READ_PASSWORD'] = ''; + + $this->assertFileDoesNotExist($this->runtimePath, 'precondition: runtime.php 부재'); + + $provider = new InstallerRuntimeServiceProvider($this->app); + $provider->register(); + + $this->assertSame('g7_user', getenv('DB_WRITE_USERNAME'), 'stale root 가 .env 의 정합 값으로 갱신되어야 함'); + $this->assertSame('real_pw', getenv('DB_WRITE_PASSWORD')); + $this->assertSame('g7_user', getenv('DB_READ_USERNAME')); + $this->assertSame('real_pw', getenv('DB_READ_PASSWORD')); + $this->assertSame('g7_user', $_ENV['DB_WRITE_USERNAME'] ?? null); + $this->assertSame('g7_user', $_SERVER['DB_WRITE_USERNAME'] ?? null); + } + + public function test_no_recovery_when_process_env_already_healthy(): void + { + $this->withProductionEnv(); + + file_put_contents($this->envPath, implode("\n", [ + 'DB_WRITE_USERNAME=g7_user', + 'DB_WRITE_PASSWORD="real_pw"', + 'INSTALLER_COMPLETED=true', + '', + ])); + + // 정상 환경 시뮬레이션 — process ENV 가 이미 정합 + putenv('DB_WRITE_USERNAME=already_correct'); + putenv('DB_WRITE_PASSWORD=already_correct_pw'); + $_ENV['DB_WRITE_USERNAME'] = 'already_correct'; + $_ENV['DB_WRITE_PASSWORD'] = 'already_correct_pw'; + + $provider = new InstallerRuntimeServiceProvider($this->app); + $provider->register(); + + // 보정 트리거 안 됨 — 정합 ENV 보존 (beta.7 보정이 .env 의 g7_user 로 덮어쓰면 안 됨) + $this->assertSame('already_correct', getenv('DB_WRITE_USERNAME'), 'stale 시그니처 아니므로 보정 미발동'); + $this->assertSame('already_correct_pw', getenv('DB_WRITE_PASSWORD')); + } + + public function test_no_recovery_when_disk_env_also_stale(): void + { + $this->withProductionEnv(); + + // 디스크 .env 도 stale (finalize 실패 분기 시뮬레이션) + file_put_contents($this->envPath, implode("\n", [ + 'DB_WRITE_USERNAME=root', + 'DB_WRITE_PASSWORD=', + 'INSTALLER_COMPLETED=true', + '', + ])); + + putenv('DB_WRITE_USERNAME=root'); + putenv('DB_WRITE_PASSWORD='); + $_ENV['DB_WRITE_USERNAME'] = 'root'; + $_ENV['DB_WRITE_PASSWORD'] = ''; + + $provider = new InstallerRuntimeServiceProvider($this->app); + $provider->register(); + + // .env 도 stale 이므로 보정 의미 없음 — 빈 값으로 덮어쓰지 않음 + $this->assertSame('root', getenv('DB_WRITE_USERNAME'), '.env 도 stale 이면 보정 미발동 (정상 ENV 손상 방지)'); + } + + public function test_no_recovery_when_installer_not_completed(): void + { + $this->withProductionEnv(); + + // INSTALLER_COMPLETED 부재 — finalize 미수행 (인스톨러 중간 상태) 시그니처 + file_put_contents($this->envPath, implode("\n", [ + 'DB_WRITE_USERNAME=g7_user', + 'DB_WRITE_PASSWORD="real_pw"', + '', + ])); + + putenv('DB_WRITE_USERNAME=root'); + $_ENV['DB_WRITE_USERNAME'] = 'root'; + + $provider = new InstallerRuntimeServiceProvider($this->app); + $provider->register(); + + // INSTALLER_COMPLETED 부재 → 보정 미발동 (인스톨러 진행 중일 수 있음, 보수적 보호) + $this->assertSame('root', getenv('DB_WRITE_USERNAME'), 'INSTALLER_COMPLETED 부재 시 보정 미발동'); + } + + public function test_no_recovery_when_runtime_php_still_present(): void + { + $this->withProductionEnv(); + + // runtime.php 잔존 — 기존 register() 가 Config 주입을 처리하므로 보정 불필요 + $this->writeRuntime([ + 'db' => ['write' => ['host' => '127.0.0.1', 'username' => 'rt_user', 'password' => 'rt_pw']], + ]); + + file_put_contents($this->envPath, implode("\n", [ + 'DB_WRITE_USERNAME=g7_user', + 'INSTALLER_COMPLETED=true', + '', + ])); + + putenv('DB_WRITE_USERNAME=root'); + $_ENV['DB_WRITE_USERNAME'] = 'root'; + + $provider = new InstallerRuntimeServiceProvider($this->app); + $provider->register(); + + // runtime.php 존재 → 기존 경로 (Config 주입) 가 동작. 본 보정은 트리거 안 됨. + $this->assertSame('root', getenv('DB_WRITE_USERNAME'), 'runtime.php 잔존 시 본 보정 미발동'); + } + /** * @param array $data */ diff --git a/tests/scenarios/installer-runtime-env-finalize.yaml b/tests/scenarios/installer-runtime-env-finalize.yaml index 2bb96d0a..640f3004 100644 --- a/tests/scenarios/installer-runtime-env-finalize.yaml +++ b/tests/scenarios/installer-runtime-env-finalize.yaml @@ -21,11 +21,24 @@ axes: env_state: [example_only, partially_filled, fully_finalized] finalize_call: [success, browser_aborted, retry_after_failure] rollback_trigger: [none, mid_install, post_finalize] + # 이슈 #371 — finalize 가드 자가 차단 회귀 axis + g7_installed_lock_state: [absent, present_before_finalize] + finalize_guard_signal: [lock_only, env_completed_true_only, lock_and_env_completed_true, neither] + orphaned_recovery_path: [none, upgrade_step_auto_finalize, upgrade_step_warn_manual_review] + # 이슈 #371 — 웹 서버 사용자/그룹 정합 axis (Upgrade Step 의 권한 조정) + cli_vs_phpfpm_user: [same_user, different_user_phpfpm_group_member, different_user_no_group_link] + env_preexisting_mode: [absent, 0600_strict, 0640_group_readable, 0664_group_writable] + bootstrap_cache_owner_group: [phpfpm_user, root_with_phpfpm_group, missing] + # 이슈 #371 — 부모 프로세스 stale ENV 가 spawn 자식에 전파되어 + # Access denied 가 발생하던 회귀 차단 axis (한시적 보정 — beta.8 이후 트리거 안 됨) + parent_process_env_state: [matches_runtime, stale_from_env_example_root, stale_partial] exclusions: - { runtime_state: absent, env_state: example_only, finalize_call: success, reason: "runtime 부재 시 finalize 는 멱등적 no-op" } - { runtime_state: present_corrupted, finalize_call: success, reason: "손상된 runtime 은 머지 미수행, runtime 보존" } - { rollback_trigger: post_finalize, runtime_state: present_with_db_and_key, reason: "finalize 후 rollback 시점에는 runtime.php 가 이미 삭제된 상태" } + - { g7_installed_lock_state: present_before_finalize, finalize_guard_signal: lock_only, finalize_call: success, reason: "이슈 #371 회귀 가드 — 락 단독 시 finalize 통과해야 함 (자가 차단 금지)" } + - { orphaned_recovery_path: upgrade_step_warn_manual_review, env_state: fully_finalized, runtime_state: absent, reason: "정상 finalize 완료 환경은 warn 경로 자체가 트리거되지 않음" } effects: - install_step5_sse_does_not_modify_env_file @@ -88,10 +101,41 @@ effects: - rollback_complete_flag_removes_g7_installed_marker_when_present - artisan_serve_workers_restart_at_most_once_after_completion_ui_shown - non_serve_environments_observe_zero_extra_restart + # 이슈 #371 — finalize 가드 자가 차단 회귀 차단 + 잔존 환경 자동 복구 + - finalize_guard_passes_when_only_g7_installed_lock_is_present + - finalize_guard_passes_when_env_lacks_installer_completed_flag + - finalize_guard_blocks_when_env_has_installer_completed_true_idempotent + - finalize_guard_blocks_when_env_has_installer_completed_true_with_quoted_value + - finalize_guard_accepts_alternative_truthy_values_1_and_yes + - upgrade_step_auto_finalizes_orphaned_runtime_when_env_not_marked_completed + - upgrade_step_skips_when_runtime_php_is_absent_idempotent_noop + - upgrade_step_warns_when_env_marked_completed_but_runtime_php_still_exists_no_auto_delete + - upgrade_step_falls_back_to_env_example_when_env_file_is_absent + - upgrade_step_preserves_runtime_php_when_env_write_fails_safe_fallback + - upgrade_step_logs_skip_when_runtime_php_returns_non_array_format_mismatch + - upgrade_step_deletes_state_json_after_merge_when_DELETE_INSTALLER_AFTER_COMPLETE_set + - upgrade_step_preserves_state_json_when_DELETE_INSTALLER_AFTER_COMPLETE_unset + # 이슈 #371 — 웹 서버 권한 자동 조정 + - upgrade_step_preserves_existing_env_mode_uid_gid_when_env_existed_before_merge + - upgrade_step_preserves_strict_0600_env_mode_when_owner_intent_present + - upgrade_step_uses_0640_default_permission_for_newly_created_env_not_0600 + - upgrade_step_detects_web_server_group_from_bootstrap_cache_for_new_env + - upgrade_step_falls_back_to_storage_logs_when_bootstrap_cache_absent + - upgrade_step_skips_chown_when_lacking_root_privilege_silent_with_log_hint + - upgrade_step_skips_uid_when_bootstrap_cache_owner_is_root_to_avoid_root_owned_env + - upgrade_step_inherits_group_only_when_bootstrap_cache_owner_is_root_but_group_is_not + # 이슈 #371 — 부모 프로세스 process ENV 도 머지된 자격증명으로 갱신하여 spawn 자식 전파 보장 + - upgrade_step_refreshes_parent_process_env_with_merged_db_write_credentials + - upgrade_step_refreshes_parent_process_env_with_merged_db_read_credentials + - upgrade_step_skips_env_refresh_when_runtime_credential_keys_are_missing + - upgrade_step_does_not_overwrite_app_key_env_when_runtime_value_lacks_base64_prefix test_files: - tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php - tests/Unit/Installer/InstallerRuntimeHelperTest.php + - tests/Unit/Installer/InstallerGuardTest.php + - tests/Feature/Console/Core/FinalizeOrphanedInstallerRuntimeTest.php + - tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php manual_verification: - scenario: A_artisan_serve diff --git a/tests/scenarios/installer-session-cookie-probe.yaml b/tests/scenarios/installer-session-cookie-probe.yaml index a08a6ac9..e302b6a9 100644 --- a/tests/scenarios/installer-session-cookie-probe.yaml +++ b/tests/scenarios/installer-session-cookie-probe.yaml @@ -9,7 +9,7 @@ description: | 비표준 포트 + 일부 브라우저 정책 조합)을 사전 감지하여 운영자에게 명시 안내한다. - 본 진단으로는 "설치하기" 버튼 자체를 차단하지 않는다 (PO 결정: 경고만). + 본 진단으로는 "설치하기" 버튼 자체를 차단하지 않는다 (정책: 경고만). 검증 시점은 Step 0 1회 한정. 또한 `session.cookie_samesite` 기본값을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄인다 — 진단은 잔여 케이스 대비 안내 역할. diff --git a/upgrades/Upgrade_7_0_0_beta_7.php b/upgrades/Upgrade_7_0_0_beta_7.php new file mode 100644 index 00000000..47243b41 --- /dev/null +++ b/upgrades/Upgrade_7_0_0_beta_7.php @@ -0,0 +1,30 @@ +runInternal($context); + } catch (\Throwable $e) { + $context->logger->warning(sprintf( + '[7.0.0-beta.7] FinalizeOrphanedInstallerRuntime 실패 (runtime.php 보존, 계속 진행): %s', + $e->getMessage(), + )); + Log::warning('beta.7 FinalizeOrphanedInstallerRuntime 실패', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + private function runInternal(UpgradeContext $context): void + { + $basePath = base_path(); + $runtimePath = $basePath.DIRECTORY_SEPARATOR.self::RUNTIME_RELATIVE; + $envPath = $basePath.DIRECTORY_SEPARATOR.'.env'; + $envExamplePath = $basePath.DIRECTORY_SEPARATOR.'.env.example'; + + if (! is_file($runtimePath)) { + $context->logger->info('[7.0.0-beta.7] runtime.php 부재 — 정상 finalize 완료 상태로 판정, skip'); + + return; + } + + if ($this->envInstallerCompletedIsTrue($envPath)) { + $context->logger->warning(sprintf( + '[7.0.0-beta.7] .env 의 INSTALLER_COMPLETED=true 이지만 %s 가 잔존. ' + .'평문 자격증명 보안 우려 — 수동 검토 후 삭제 권장', + self::RUNTIME_RELATIVE, + )); + + return; + } + + $runtime = $this->readRuntime($runtimePath); + if ($runtime === null) { + $context->logger->info('[7.0.0-beta.7] runtime.php 형식 불일치 (배열 아님) — runtime.php 보존, skip'); + + return; + } + + $envBase = $this->loadEnvBase($envPath, $envExamplePath); + if ($envBase === null) { + $context->logger->error('[7.0.0-beta.7] .env / .env.example 모두 읽기 실패 — runtime.php 보존'); + + return; + } + + // 머지 *전에* .env 의 기존 권한/소유자/그룹 스냅샷을 떠둔다 — 머지 후 그대로 복원해 + // CLI 실행 사용자(jjh/root) 와 PHP-FPM 사용자(www-data) 가 다른 환경에서도 + // PHP-FPM 이 계속 .env 를 읽을 수 있도록 한다. + $envExisted = is_file($envPath); + $preservedStat = $envExisted ? $this->snapshotFileStat($envPath) : null; + + $merged = $this->mergeRuntimeIntoEnv($envBase, $runtime); + + if (@file_put_contents($envPath, $merged, LOCK_EX) === false) { + $context->logger->error('[7.0.0-beta.7] .env 쓰기 실패 — runtime.php 보존'); + + return; + } + + $this->applyEnvPermissions($envPath, $preservedStat, $basePath, $context); + + // 부모 프로세스의 process ENV 도 머지된 자격증명으로 갱신 (이슈 #371 후속 회귀 차단) + // + // 배경: beta.4~6 손상 환경에서는 .env 가 .env.example 그대로 (DB_WRITE_USERNAME=root, + // DB_WRITE_PASSWORD= 등) 이고 자격증명은 runtime.php 에 잔존. 부모 프로세스 부팅 + // 시점에 Dotenv 가 stale .env 의 값을 process ENV (getenv() / $_ENV / $_SERVER) + // 에 적재한 상태이며, Config 메모리만 InstallerRuntimeServiceProvider 가 정상값으로 + // 주입해두었기 때문에 부모 자체는 정상 동작한다. + // + // 이 시점에 본 마이그레이션이 .env 머지 + runtime.php 삭제를 수행해도 부모의 process + // ENV 는 stale 한 채 유지된다. 그 직후 BundledExtensionUpdatePrompt 의 번들 일괄 + // 업데이트가 proc_open 으로 spawn 자식을 띄울 때 5번째 인자에 array_merge(getenv(), + // $_ENV) 를 전달하므로 자식이 stale 값(root / 빈 비밀번호)을 그대로 상속받는다. + // 자식의 Laravel 부팅 시 Dotenv::createImmutable() 은 이미 채워진 ENV 를 덮어쓰지 + // 않으므로 자식이 머지된 .env 의 정상 값을 보지 못하고 root@localhost (using + // password: YES — 환경에 따라 다른 stale 비밀번호) 로 DB 연결 시도 → Access denied. + // + // beta.8 이후 정상 .env 환경에서는 부팅 시점부터 process ENV 가 올바르므로 본 + // 보정 로직이 트리거되어도 동일 값으로 재기록되는 멱등 동작이며 부작용이 없다. + $this->refreshProcessEnvFromRuntime($runtime); + + @unlink($runtimePath); + + if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) { + $stateFilePath = $basePath.DIRECTORY_SEPARATOR.self::STATE_JSON_RELATIVE; + if (is_file($stateFilePath)) { + @unlink($stateFilePath); + } + } + + // 본 PR 부수효과 정리 — beta.7 업그레이드 도중 자식 spawn 프로세스 (root 권한) + // 가 부팅하면서 InstallerRuntimeServiceProvider 의 recover 가 발화 → Config + // 보정 → CoreServiceProvider DB 가드 통과 → loadModules() → CachesModuleStatus + // 의 캐시 쓰기 흐름이 진행되어, storage/framework/cache/data// 디렉토리가 + // root:root 권한으로 생성됨. 이후 일반 PHP-FPM 워커(www-data 그룹)가 그 + // 디렉토리에 캐시 파일 쓰기 시도 시 Permission denied → 사이트 500 회귀. + // + // 정상 운영 디렉토리 (storage/, bootstrap/cache 등) 의 소유자/그룹을 기준으로 + // storage/framework/cache/data/ 하위의 root 소유 항목만 일괄 chown. + $this->normalizeCachePermissions($basePath, $context); + + $context->logger->info( + '[7.0.0-beta.7] 잔존 runtime.php 자동 finalize 완료 — .env 머지 + runtime.php 삭제 + state.json 정리' + ); + } + + /** + * beta.7 업그레이드 부수효과 보정 — storage/framework/cache/data/ 하위의 root 소유 + * 디렉토리/파일을 정상 소유자로 chown. + * + * 정상 소유자/그룹 결정: + * - 1순위: storage/framework/cache/data/ 자신의 owner/group (이미 정상값을 보유) + * - 2순위: bootstrap/cache/ 의 owner/group + * - 3순위: 그 외 — skip (안전한 fallback 없으면 강제 변경 안 함) + * + * @param string $basePath 프로젝트 base path + */ + private function normalizeCachePermissions(string $basePath, UpgradeContext $context): void + { + if (! function_exists('chown') || ! function_exists('posix_geteuid')) { + return; + } + + $cacheRoot = $basePath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'framework'.DIRECTORY_SEPARATOR.'cache'.DIRECTORY_SEPARATOR.'data'; + if (! is_dir($cacheRoot)) { + return; + } + + // 정상 owner/group 추정 + [$targetUid, $targetGid] = $this->inferCacheOwnership($basePath, $cacheRoot); + if ($targetUid === null || $targetGid === null) { + $context->logger->info('[7.0.0-beta.7] 캐시 권한 보정 skip — 정상 owner/group 추정 실패'); + + return; + } + + $changed = 0; + $failed = 0; + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($cacheRoot, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iter as $entry) { + /** @var \SplFileInfo $entry */ + $path = $entry->getPathname(); + $currentUid = @fileowner($path); + $currentGid = @filegroup($path); + // 이미 정상이면 skip + if ($currentUid === $targetUid && $currentGid === $targetGid) { + continue; + } + // root (uid=0) 소유 항목만 보정 — 사용자가 의도해서 만든 다른 소유자는 보존 + if ($currentUid !== 0 && $currentGid !== 0) { + continue; + } + $okOwner = @chown($path, $targetUid); + $okGroup = @chgrp($path, $targetGid); + if ($okOwner && $okGroup) { + $changed++; + } else { + $failed++; + } + } + + $context->logger->info(sprintf( + '[7.0.0-beta.7] 캐시 권한 보정 완료 — root 소유 항목 changed=%d failed=%d (uid=%d gid=%d)', + $changed, + $failed, + $targetUid, + $targetGid, + )); + } + + /** + * 캐시 디렉토리의 정상 owner/group 추정. + * + * @return array{0:int|null, 1:int|null} + */ + private function inferCacheOwnership(string $basePath, string $cacheRoot): array + { + // 1순위: cache/data 자신의 owner/group (chown 대상 자식이 root 라도 부모는 정상값일 가능성) + $uid = @fileowner($cacheRoot); + $gid = @filegroup($cacheRoot); + if (is_int($uid) && $uid !== 0 && is_int($gid)) { + return [$uid, $gid]; + } + + // 2순위: bootstrap/cache + $bootstrapCache = $basePath.DIRECTORY_SEPARATOR.'bootstrap'.DIRECTORY_SEPARATOR.'cache'; + if (is_dir($bootstrapCache)) { + $uid = @fileowner($bootstrapCache); + $gid = @filegroup($bootstrapCache); + if (is_int($uid) && $uid !== 0 && is_int($gid)) { + return [$uid, $gid]; + } + } + + // 3순위 fallback 없음 + return [null, null]; + } + + /** + * `.env` 의 INSTALLER_COMPLETED 가 truthy 값인지 판정. + * + * `_guard.php` 의 installer_finalize_is_completed() 와 동일 정책. + */ + private function envInstallerCompletedIsTrue(string $envPath): bool + { + if (! is_file($envPath)) { + return false; + } + $parsed = @parse_ini_file($envPath, false, INI_SCANNER_RAW); + if (! is_array($parsed)) { + return false; + } + $flag = strtolower(trim((string) ($parsed['INSTALLER_COMPLETED'] ?? ''))); + $flag = trim($flag, "\"'"); + + return in_array($flag, ['true', '1', 'yes'], true); + } + + /** + * runtime.php 를 읽어 배열 반환. 부재/형식 불일치 시 null. + * + * installer-runtime.php 의 readInstallerRuntime() 와 동일 로직 — 본 클래스 + * 안에 중복 구현하여 BASE_PATH 상수 의존 회피 (V-1 안전 격리). + */ + private function readRuntime(string $runtimePath): ?array + { + if (! is_file($runtimePath)) { + return null; + } + $data = @include $runtimePath; + + return is_array($data) ? $data : null; + } + + /** + * `.env` 가 존재하면 그 내용을, 아니면 `.env.example` 을 base 로 반환. + * + * 둘 다 없으면 null. + */ + private function loadEnvBase(string $envPath, string $envExamplePath): ?string + { + if (is_file($envPath)) { + $content = @file_get_contents($envPath); + + return $content === false ? null : $content; + } + if (is_file($envExamplePath)) { + $content = @file_get_contents($envExamplePath); + + return $content === false ? null : $content; + } + + return null; + } + + /** + * runtime 배열을 .env 본문에 머지. + * + * installer-runtime.php 의 mergeRuntimeIntoEnv() 와 동일 결과 — escapeEnvValue + * / replaceEnvLine 을 로컬 private 메서드로 인라인 구현. + */ + private function mergeRuntimeIntoEnv(string $envContent, array $runtime): string + { + $write = $runtime['db']['write'] ?? null; + if (is_array($write)) { + $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_HOST', (string) ($write['host'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_PORT', (string) ($write['port'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_DATABASE', (string) ($write['database'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_USERNAME', (string) ($write['username'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', $this->escapeEnvValue((string) ($write['password'] ?? ''))); + } + + $read = $runtime['db']['read'] ?? $write; + if (is_array($read)) { + $envContent = $this->replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? '')); + $envContent = $this->replaceEnvLine($envContent, 'DB_READ_PASSWORD', $this->escapeEnvValue((string) ($read['password'] ?? ''))); + } + + if (isset($runtime['db']['prefix'])) { + $envContent = $this->replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']); + } + + $appKey = $runtime['app']['key'] ?? null; + if (is_string($appKey) && str_starts_with($appKey, 'base64:')) { + $envContent = $this->replaceEnvLine($envContent, 'APP_KEY', $appKey); + } + + if (! preg_match('/^INSTALLER_COMPLETED=/m', $envContent)) { + $envContent = rtrim($envContent)."\n\n# Installation Status\nINSTALLER_COMPLETED=true\n"; + } + + return $envContent; + } + + /** + * 부모 프로세스의 process ENV 를 runtime 자격증명으로 갱신. + * + * getenv() / $_ENV / $_SERVER 3곳 모두 갱신 — proc_open 의 ENV 합집합 전파와 + * Dotenv::createImmutable() 의 덮어쓰기 차단 정책 모두에 대응. + * + * 트리거 가드는 호출자에서 이미 보장됨: + * - runtime.php 부재 (beta.3 정상 환경 포함) → runInternal 진입 직후 return + * - .env 의 INSTALLER_COMPLETED=true (이미 finalize 완료) → 별도 return + * - 따라서 본 메서드 도달 = beta.4~6 손상 환경의 머지 성공 분기 + * + * 본 메서드 자체도 빈 값 가드: runtime 의 username/password 키가 부재하거나 + * 빈 문자열이면 그 키에 대한 ENV 갱신을 skip 하여 정상 ENV 를 빈 값으로 + * 덮어쓰는 회귀를 차단. + * + * @param array $runtime + */ + private function refreshProcessEnvFromRuntime(array $runtime): void + { + $write = $runtime['db']['write'] ?? null; + if (! is_array($write)) { + return; + } + $read = $runtime['db']['read'] ?? $write; + if (! is_array($read)) { + $read = $write; + } + + $pairs = [ + 'DB_WRITE_HOST' => $write['host'] ?? null, + 'DB_WRITE_PORT' => $write['port'] ?? null, + 'DB_WRITE_DATABASE' => $write['database'] ?? null, + 'DB_WRITE_USERNAME' => $write['username'] ?? null, + 'DB_WRITE_PASSWORD' => $write['password'] ?? null, + 'DB_READ_HOST' => $read['host'] ?? null, + 'DB_READ_PORT' => $read['port'] ?? null, + 'DB_READ_DATABASE' => $read['database'] ?? null, + 'DB_READ_USERNAME' => $read['username'] ?? null, + 'DB_READ_PASSWORD' => $read['password'] ?? null, + ]; + + if (isset($runtime['db']['prefix'])) { + $pairs['DB_PREFIX'] = (string) $runtime['db']['prefix']; + } + $appKey = $runtime['app']['key'] ?? null; + if (is_string($appKey) && str_starts_with($appKey, 'base64:')) { + $pairs['APP_KEY'] = $appKey; + } + + foreach ($pairs as $key => $value) { + // null / 빈 문자열은 skip — 정상 ENV 를 빈 값으로 덮어쓰는 회귀 차단 + // (단, 비밀번호는 의도적으로 빈 문자열일 수 있으므로 password 만 예외 허용) + if ($value === null) { + continue; + } + $stringValue = (string) $value; + if ($stringValue === '' && ! in_array($key, ['DB_WRITE_PASSWORD', 'DB_READ_PASSWORD', 'DB_PREFIX'], true)) { + continue; + } + + putenv($key.'='.$stringValue); + $_ENV[$key] = $stringValue; + $_SERVER[$key] = $stringValue; + } + } + + private function escapeEnvValue(string $value): string + { + if ($value !== '') { + $value = str_replace(["\r", "\n"], '', $value); + } + if ($value === '') { + return '""'; + } + $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + + return '"'.$escaped.'"'; + } + + private 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; + } + + // --------------------------------------------------------------------- + // 권한·소유자·그룹 자동 조정 — 코어/인스톨러 헬퍼 비의존 (V-1 안전 격리) + // --------------------------------------------------------------------- + + /** + * 파일의 권한/소유자/그룹을 스냅샷. + * + * @return array{mode:int|null, uid:int|null, gid:int|null}|null 파일 부재/stat 실패 시 null + */ + private function snapshotFileStat(string $path): ?array + { + if (! is_file($path)) { + return null; + } + $perms = @fileperms($path); + $uid = @fileowner($path); + $gid = @filegroup($path); + + if ($perms === false && $uid === false && $gid === false) { + return null; + } + + return [ + 'mode' => $perms === false ? null : ($perms & 0777), + 'uid' => $uid === false ? null : $uid, + 'gid' => $gid === false ? null : $gid, + ]; + } + + /** + * 머지된 .env 에 적절한 권한·소유자·그룹을 적용. + * + * 정책: + * 1. 기존 .env 가 있었으면 스냅샷 그대로 복원 — 운영자가 의도해서 설정한 권한 보존 + * (PHP-FPM 사용자가 그룹으로 묶여 있는 자체 구축 환경 / 단일 사용자 카페24 환경 + * 모두 자동 보존) + * 2. 신규 생성된 경우 → 웹 서버 컨텍스트 (소유자/그룹) 를 추정하여 적용 + * - 추정: bootstrap/cache/ 의 소유자/그룹 (PHP-FPM 이 쓰는 영역) + * - 권한: 0640 (소유자 rw + 그룹 r) — 비밀번호 노출 차단 + 웹 서버 그룹 읽기 허용 + * + * 모든 chown/chgrp/chmod 호출은 실패해도 진행 (운영자가 수동 보정 가능하도록 로그 안내). + * + * @param array{mode:int|null, uid:int|null, gid:int|null}|null $preservedStat + * 머지 직전 .env 가 존재했다면 그 stat. 부재였으면 null. + */ + private function applyEnvPermissions( + string $envPath, + ?array $preservedStat, + string $basePath, + UpgradeContext $context, + ): void { + if ($preservedStat !== null) { + // 기존 .env 의 권한/소유자/그룹 복원 + $this->restoreFileStat($envPath, $preservedStat, $context); + + return; + } + + // 신규 생성 .env — 웹 서버 컨텍스트 추정 + 안전한 기본 권한 적용 + $webContext = $this->detectWebServerContext($basePath); + $processUser = $this->describeProcessUser(); + + $context->logger->info(sprintf( + '[7.0.0-beta.7] .env 신규 생성 시작 — 프로세스 사용자=%s, 웹 서버 컨텍스트 추정: uid=%s, gid=%s', + $processUser, + $webContext['uid'] !== null ? (string) $webContext['uid'] : 'unknown', + $webContext['gid'] !== null ? (string) $webContext['gid'] : 'unknown', + )); + + if ($webContext['uid'] !== null) { + $this->tryChown($envPath, $webContext['uid'], $context); + } else { + $context->logger->info('[7.0.0-beta.7] chown 시도 skip — 웹 서버 사용자 추정 실패 (bootstrap/cache, storage/logs 모두 신호 없음)'); + } + if ($webContext['gid'] !== null) { + $this->tryChgrp($envPath, $webContext['gid'], $context); + } else { + $context->logger->info('[7.0.0-beta.7] chgrp 시도 skip — 웹 서버 그룹 추정 실패'); + } + + $chmodResult = @chmod($envPath, self::DEFAULT_ENV_PERMISSIONS); + $actualMode = fileperms($envPath) & 0777; + + if ($chmodResult === false) { + $lastError = error_get_last(); + $context->logger->warning(sprintf( + '[7.0.0-beta.7] chmod(.env, %s) FAILED — 실제 권한: %s, 프로세스=%s, last_error=%s. ' + .'운영자 수동 보정: chmod %s %s', + decoct(self::DEFAULT_ENV_PERMISSIONS), + decoct($actualMode), + $processUser, + $lastError['message'] ?? 'unknown', + decoct(self::DEFAULT_ENV_PERMISSIONS), + $envPath, + )); + } else { + $context->logger->info(sprintf( + '[7.0.0-beta.7] chmod(.env, %s) 성공 — 실제 권한: %s, 프로세스=%s', + decoct(self::DEFAULT_ENV_PERMISSIONS), + decoct($actualMode), + $processUser, + )); + } + + $context->logger->info(sprintf( + '[7.0.0-beta.7] .env 신규 생성 완료 — 최종 mode=%s, owner_uid=%s, group_gid=%s', + decoct($actualMode), + (string) (@fileowner($envPath) ?: 'unknown'), + (string) (@filegroup($envPath) ?: 'unknown'), + )); + } + + /** + * 스냅샷 stat 을 파일에 그대로 복원. + * + * 우선순위: chmod → chown → chgrp. chown 은 root 권한 필요할 수 있어 실패 흔함 — 실패해도 진행. + */ + private function restoreFileStat(string $path, array $stat, UpgradeContext $context): void + { + $processUser = $this->describeProcessUser(); + + if ($stat['mode'] !== null) { + $chmodResult = @chmod($path, $stat['mode']); + $actualMode = fileperms($path) & 0777; + if ($chmodResult === false) { + $lastError = error_get_last(); + $context->logger->warning(sprintf( + '[7.0.0-beta.7] chmod(%s, %s) FAILED — 실제 권한: %s, 프로세스=%s, last_error=%s', + basename($path), + decoct($stat['mode']), + decoct($actualMode), + $processUser, + $lastError['message'] ?? 'unknown', + )); + } + } + if ($stat['uid'] !== null) { + $this->tryChown($path, $stat['uid'], $context); + } + if ($stat['gid'] !== null) { + $this->tryChgrp($path, $stat['gid'], $context); + } + + $context->logger->info(sprintf( + '[7.0.0-beta.7] .env 기존 권한 보존 시도 — 요청 mode=%s, uid=%s, gid=%s / 실제 mode=%s, uid=%s, gid=%s, 프로세스=%s', + $stat['mode'] !== null ? decoct($stat['mode']) : 'unchanged', + $stat['uid'] !== null ? (string) $stat['uid'] : 'unchanged', + $stat['gid'] !== null ? (string) $stat['gid'] : 'unchanged', + decoct(fileperms($path) & 0777), + (string) (@fileowner($path) ?: 'unknown'), + (string) (@filegroup($path) ?: 'unknown'), + $processUser, + )); + } + + /** + * 현재 프로세스 사용자 ("name(uid)") 를 문자열로 반환 — 로그 가독성용. + */ + private function describeProcessUser(): string + { + if (! function_exists('posix_geteuid')) { + return 'unknown(no-posix)'; + } + $euid = posix_geteuid(); + $name = function_exists('posix_getpwuid') ? (posix_getpwuid($euid)['name'] ?? null) : null; + + return ($name ?? 'uid') . '(' . $euid . ')'; + } + + /** + * 웹 서버 컨텍스트 (PHP-FPM/Apache 가 쓰는 사용자/그룹) 를 디스크 신호로 추정. + * + * `getWebServerUser()` 같은 인스톨러 헬퍼 (현재 PHP 프로세스 = web user 가정) 는 + * CLI 컨텍스트에서 동작하지 않으므로 사용 금지. 대신 PHP-FPM 이 실제로 *쓴 파일* + * 의 소유자/그룹을 신호로 사용. + * + * 우선순위: + * 1. bootstrap/cache/*.php (services.php / packages.php) — Laravel 부팅 시 PHP-FPM + * 이 직접 쓰는 파일. 가장 신뢰도 높음 + * 2. bootstrap/cache/ 디렉토리 자체 + * 3. storage/logs/ 디렉토리 (PHP-FPM 의 또 다른 쓰기 영역) + * 4. 모두 실패 시 null — 호출자가 chown/chgrp skip + * + * @return array{uid:int|null, gid:int|null} + */ + private function detectWebServerContext(string $basePath): array + { + $candidates = [ + $basePath.DIRECTORY_SEPARATOR.'bootstrap/cache/services.php', + $basePath.DIRECTORY_SEPARATOR.'bootstrap/cache/packages.php', + $basePath.DIRECTORY_SEPARATOR.'bootstrap/cache', + $basePath.DIRECTORY_SEPARATOR.'storage/logs', + ]; + + foreach ($candidates as $candidate) { + if (! file_exists($candidate)) { + continue; + } + $uid = @fileowner($candidate); + $gid = @filegroup($candidate); + if ($uid === false && $gid === false) { + continue; + } + + // root (uid=0) 단독 신호는 회피 — sudo 로 캐시가 생성된 환경일 수 있음. + // 그룹은 root 라도 사용 가능 (그룹이 web user group 인 경우 흔함). + if ($uid === 0 && $gid !== false && $gid !== 0) { + return ['uid' => null, 'gid' => $gid]; + } + + return [ + 'uid' => $uid === false ? null : $uid, + 'gid' => $gid === false ? null : $gid, + ]; + } + + return ['uid' => null, 'gid' => null]; + } + + /** + * chown 시도. 시도/성공/실패 모두 로그에 명시. + */ + private function tryChown(string $path, int $uid, UpgradeContext $context): void + { + if (! function_exists('chown')) { + $context->logger->info('[7.0.0-beta.7] chown skip — chown() 함수 사용 불가 (PHP 빌드 옵션)'); + + return; + } + $beforeUid = @fileowner($path); + $result = @chown($path, $uid); + $afterUid = @fileowner($path); + + if ($result === false) { + $lastError = error_get_last(); + $context->logger->info(sprintf( + '[7.0.0-beta.7] chown(%s, uid=%d) FAILED — before=%s, after=%s, 프로세스=%s, last_error=%s. ' + .'원인 추정: 비-root 사용자는 다른 사용자로 chown 불가. 운영자 수동 보정: sudo chown %d %s', + basename($path), + $uid, + $beforeUid === false ? 'unknown' : (string) $beforeUid, + $afterUid === false ? 'unknown' : (string) $afterUid, + $this->describeProcessUser(), + $lastError['message'] ?? 'unknown', + $uid, + $path, + )); + } else { + $context->logger->info(sprintf( + '[7.0.0-beta.7] chown(%s, uid=%d) 성공 — before=%s → after=%s', + basename($path), + $uid, + $beforeUid === false ? 'unknown' : (string) $beforeUid, + $afterUid === false ? 'unknown' : (string) $afterUid, + )); + } + } + + /** + * chgrp 시도. 시도/성공/실패 모두 로그에 명시. + */ + private function tryChgrp(string $path, int $gid, UpgradeContext $context): void + { + if (! function_exists('chgrp')) { + $context->logger->info('[7.0.0-beta.7] chgrp skip — chgrp() 함수 사용 불가'); + + return; + } + $beforeGid = @filegroup($path); + $result = @chgrp($path, $gid); + $afterGid = @filegroup($path); + + if ($result === false) { + $lastError = error_get_last(); + $context->logger->info(sprintf( + '[7.0.0-beta.7] chgrp(%s, gid=%d) FAILED — before=%s, after=%s, 프로세스=%s, last_error=%s. ' + .'운영자 수동 보정: sudo chgrp %d %s', + basename($path), + $gid, + $beforeGid === false ? 'unknown' : (string) $beforeGid, + $afterGid === false ? 'unknown' : (string) $afterGid, + $this->describeProcessUser(), + $lastError['message'] ?? 'unknown', + $gid, + $path, + )); + } else { + $context->logger->info(sprintf( + '[7.0.0-beta.7] chgrp(%s, gid=%d) 성공 — before=%s → after=%s', + basename($path), + $gid, + $beforeGid === false ? 'unknown' : (string) $beforeGid, + $afterGid === false ? 'unknown' : (string) $afterGid, + )); + } + } +}