From a7b7c6457382d56376a57ed0f1850dc51accccb1 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Thu, 20 Aug 2026 17:01:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=EC=BD=94=EC=96=B4=20?= =?UTF-8?q?=EC=95=84=EC=9B=83=EB=B0=94=EC=9A=B4=EB=93=9C=20HTTP=20?= =?UTF-8?q?=ED=94=84=EB=A1=9D=EC=8B=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 접속 IP 를 제한하는 결제사 API 를 로컬·스테이징에서 연동하려면 서버가 내보내는 요청의 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는 축이라 코어 환경설정으로 도입한다. 게이트는 디버그 모드이며 판정은 OutboundProxy 한 곳이 소유한다. 화면의 조건부 렌더링은 편의일 뿐이라 저장 API 직접 호출을 막지 못하므로, 실질 게이트를 판정 지점에 둔다. 주입·적용 지점은 결과만 소비한다. 적용은 Http::globalOptions 전역 옵션이라 확장의 Http:: 호출까지 함께 경유한다. 외부 연동 규약상 curl 핸들을 직접 다뤄야 하는 확장은 OutboundProxy::curlOptions 로 같은 프록시를 탄다 — KG이니시스 본인인증 승인 요청과 CBT 연결 점검을 이 통로로 편입했다. CBT 의 TCP 443 확인은 원시 소켓으로는 프록시를 태울 수 없어 curl CONNECT_ONLY 로 교체했다. 저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고, 그 프록시를 거쳤을 때 외부에 보이는 IP 를 함께 보고한다. 운영자가 결제사에 등록할 값이라 저장하고 나서 되짚지 않도록 했다. 적용과 같은 조립을 거치므로 확인한 구성과 저장 후 적용되는 구성이 어긋나지 않는다. --- .env.example | 2 +- .env.testing.example | 2 +- CHANGELOG.md | 8 + INSTALL.md | 2 +- README.ko.md | 2 +- README.md | 2 +- .../Commands/MigrateSettingsToJsonCommand.php | 2 + .../Api/Admin/SettingsController.php | 31 +- .../Requests/Settings/SaveSettingsRequest.php | 17 + .../Settings/TestOutboundProxyRequest.php | 76 +++++ app/Providers/AppServiceProvider.php | 27 ++ app/Providers/SettingsServiceProvider.php | 6 + app/Rules/ValidOutboundProxyUrl.php | 47 +++ app/Services/OutboundProxyTester.php | 116 +++++++ app/Support/ApiDoc/ParameterDescriber.php | 2 + app/Support/OutboundProxy.php | 191 +++++++++++ config/app.php | 2 +- config/core.php | 21 ++ config/settings/defaults.json | 8 +- docs/backend/admin-settings-access.md | 41 +++ docs/backend/api/settings.md | 76 +++++ lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 7 + .../g7-core-ja/backend/ja/settings.php | 6 + .../g7-core-ja/backend/ja/validation.php | 11 + .../_bundled/g7-core-ja/language-pack.json | 2 +- .../CHANGELOG.md | 6 + .../frontend/partial/admin.json | 7 + .../language-pack.json | 2 +- lang/en/settings.php | 6 + lang/en/validation.php | 11 + lang/ko/settings.php | 6 + lang/ko/validation.php | 11 + .../sirsoft-pay_kginicis/CHANGELOG.md | 2 + .../sirsoft-pay_kginicis/docs/api/cbt.md | 4 +- .../_bundled/sirsoft-pay_kginicis/plugin.json | 2 +- .../AdminCbtConnectivityCheckController.php | 49 ++- .../CHANGELOG.md | 7 + .../composer.json | 2 +- .../package.json | 2 +- .../sirsoft-verification_kginicis/plugin.json | 4 +- .../src/Services/InicisGateway.php | 7 + routes/api.php | 1 + .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 7 + ...ettings-outbound-proxy-visibility.test.tsx | 203 +++++++++++ .../editor-spec/sampleData.json | 6 +- .../lang/partial/en/admin.json | 7 + .../lang/partial/ko/admin.json | 7 + .../admin_settings/_tab_advanced.json | 213 ++++++++++++ .../sirsoft-admin_basic/package-lock.json | 4 +- .../_bundled/sirsoft-admin_basic/package.json | 2 +- .../sirsoft-admin_basic/template.json | 2 +- .../Settings/OutboundProxySettingTest.php | 322 ++++++++++++++++++ .../admin/settings-outbound-proxy.spec.ts | 152 +++++++++ tests/Unit/Support/OutboundProxyTest.php | 258 ++++++++++++++ tests/scenarios/outbound-http-proxy.yaml | 51 +++ 55 files changed, 2040 insertions(+), 30 deletions(-) create mode 100644 app/Http/Requests/Settings/TestOutboundProxyRequest.php create mode 100644 app/Rules/ValidOutboundProxyUrl.php create mode 100644 app/Services/OutboundProxyTester.php create mode 100644 app/Support/OutboundProxy.php create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx create mode 100644 tests/Feature/Settings/OutboundProxySettingTest.php create mode 100644 tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts create mode 100644 tests/Unit/Support/OutboundProxyTest.php create mode 100644 tests/scenarios/outbound-http-proxy.yaml diff --git a/.env.example b/.env.example index 373dd7dd..aae511b2 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.7 +APP_VERSION=7.1.0 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 6aaace74..4635cbd2 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.7 +APP_VERSION=7.1.0 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dccead..6295dc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.1.0] - 2026-08-20 + +### Added + +- 사이트가 외부로 보내는 요청을 지정한 프록시 서버를 거쳐 나가도록 설정할 수 있습니다. 결제사처럼 접속 IP 를 제한하는 외부 서비스를 연동할 때, 개발·스테이징 환경에서도 허용된 IP 로 요청을 보낼 수 있습니다. 환경설정 > 고급에서 디버그 모드를 켜면 프록시 주소 입력칸이 나타나며, 프록시를 거치지 않을 주소를 예외 목록으로 따로 지정할 수 있습니다. 디버그 모드를 끄면 저장된 주소가 남아 있어도 프록시는 적용되지 않습니다. +- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다. +- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다. + ## [7.0.7] - 2026-08-19 ### Security diff --git a/INSTALL.md b/INSTALL.md index 802b5d98..e6a9c93d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -289,7 +289,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.7 g7 +# (필요 시) mv g7-7.1.0 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.ko.md b/README.ko.md index 06a43295..dda31a78 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,7 +10,7 @@

- Version + Version PHP Laravel React diff --git a/README.md b/README.md index 3410c40b..506ff5c6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version PHP Laravel React diff --git a/app/Console/Commands/MigrateSettingsToJsonCommand.php b/app/Console/Commands/MigrateSettingsToJsonCommand.php index 925d969a..9ef967dd 100644 --- a/app/Console/Commands/MigrateSettingsToJsonCommand.php +++ b/app/Console/Commands/MigrateSettingsToJsonCommand.php @@ -96,6 +96,8 @@ class MigrateSettingsToJsonCommand extends Command 'debug_mode' => 'debug', 'sql_query_log' => 'debug', 'log_level' => 'debug', + 'outbound_proxy' => 'debug', + 'outbound_proxy_bypass' => 'debug', ]; /** diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index 3dc58098..dd00c684 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -8,10 +8,12 @@ use App\Http\Requests\Settings\RestoreSettingsRequest; use App\Http\Requests\Settings\SaveSettingsRequest; use App\Http\Requests\Settings\TestDriverConnectionRequest; use App\Http\Requests\Settings\TestMailRequest; +use App\Http\Requests\Settings\TestOutboundProxyRequest; use App\Http\Requests\Settings\UpdateSettingRequest; use App\Http\Resources\SettingsResource; use App\Services\DriverConnectionTester; use App\Services\DriverRegistryService; +use App\Services\OutboundProxyTester; use App\Services\SettingsService; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; @@ -27,7 +29,8 @@ class SettingsController extends AdminBaseController public function __construct( private SettingsService $settingsService, private DriverConnectionTester $driverConnectionTester, - private DriverRegistryService $driverRegistryService + private DriverRegistryService $driverRegistryService, + private OutboundProxyTester $outboundProxyTester ) { parent::__construct(); } @@ -340,4 +343,30 @@ class SettingsController extends AdminBaseController return $this->error('settings.driver_test_error', 500, $e->getMessage()); } } + + /** + * 아웃바운드 프록시 연결을 테스트합니다. + * + * 저장하기 전에 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 나갔을 때 상대편에 + * 어떤 IP 로 보이는지 확인합니다. 출발지 IP 는 운영자가 결제사·외부 서비스에 등록해야 + * 하는 값이라 결과의 핵심입니다. + * + * 검사 대상은 저장된 설정이 아니라 이번 요청이 제출한 값입니다. + * + * @param TestOutboundProxyRequest $request 검증된 요청 + * @return JsonResponse 검사 결과 + */ + public function testOutboundProxy(TestOutboundProxyRequest $request): JsonResponse + { + $validated = $request->validated(); + + $result = $this->outboundProxyTester->test( + (string) $validated['outbound_proxy'], + (array) ($validated['outbound_proxy_bypass'] ?? []) + ); + + // 연결 실패는 요청 처리 실패가 아니라 진단 결과다 — 200 으로 결과를 돌려주고 + // 성공 여부는 페이로드가 말한다 (드라이버 연결 테스트와 같은 규약). + return $this->success($result['message_key'], $result); + } } diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index a7da06c5..6ce8165a 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Settings; use App\Extension\HookManager; use App\Models\Attachment; +use App\Rules\ValidOutboundProxyUrl; use App\Search\Engines\DatabaseFulltextEngine; use App\Services\DriverRegistryService; use App\Support\AllowedExtensions; @@ -296,6 +297,13 @@ class SaveSettingsRequest extends FormRequest 'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'), 'advanced.sql_query_log' => $this->getTabRules($tab, 'advanced', 'boolean'), + // 아웃바운드 HTTP 프록시 (advanced 탭) + // 디버그 모드 OFF 시 하위 필드는 collapse 되어 미전송됨 → nullable 필수 + // (geoip 하위 필드와 같은 사유 — 조건부 렌더링 내부에 있다) + 'advanced.outbound_proxy' => ['nullable', 'string', 'max:500', new ValidOutboundProxyUrl], + 'advanced.outbound_proxy_bypass' => ['nullable', 'array'], + 'advanced.outbound_proxy_bypass.*' => ['string', 'max:255'], + // 코어 업데이트 설정 (advanced 탭) 'advanced.core_update_github_url' => ['nullable', 'url', 'max:500'], 'advanced.core_update_github_token' => ['nullable', 'string', 'max:500'], @@ -779,6 +787,13 @@ class SaveSettingsRequest extends FormRequest 'advanced.sql_query_log.required' => __('validation.settings.sql_query_log_required'), 'advanced.sql_query_log.boolean' => __('validation.settings.sql_query_log_boolean'), + // 아웃바운드 HTTP 프록시 + 'advanced.outbound_proxy.string' => __('validation.settings.outbound_proxy_string'), + 'advanced.outbound_proxy.max' => __('validation.settings.outbound_proxy_max'), + 'advanced.outbound_proxy_bypass.array' => __('validation.settings.outbound_proxy_bypass_array'), + 'advanced.outbound_proxy_bypass.*.string' => __('validation.settings.outbound_proxy_bypass_item_string'), + 'advanced.outbound_proxy_bypass.*.max' => __('validation.settings.outbound_proxy_bypass_item_max'), + // 목록 한계값 'advanced.pagination_result_cap.integer' => __('validation.settings.pagination_result_cap_integer'), 'advanced.pagination_result_cap.min' => __('validation.settings.pagination_result_cap_min'), @@ -972,6 +987,8 @@ class SaveSettingsRequest extends FormRequest 'advanced.seo_sitemap_cache_ttl' => __('validation.attributes.seo_sitemap_cache_ttl'), 'advanced.debug_mode' => __('validation.attributes.debug_mode'), 'advanced.sql_query_log' => __('validation.attributes.sql_query_log'), + 'advanced.outbound_proxy' => __('validation.attributes.outbound_proxy'), + 'advanced.outbound_proxy_bypass' => __('validation.attributes.outbound_proxy_bypass'), 'advanced.core_update_github_url' => __('validation.attributes.core_update_github_url'), 'advanced.core_update_github_token' => __('validation.attributes.core_update_github_token'), 'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'), diff --git a/app/Http/Requests/Settings/TestOutboundProxyRequest.php b/app/Http/Requests/Settings/TestOutboundProxyRequest.php new file mode 100644 index 00000000..17e93828 --- /dev/null +++ b/app/Http/Requests/Settings/TestOutboundProxyRequest.php @@ -0,0 +1,76 @@ +|string> + */ + public function rules(): array + { + $rules = [ + 'outbound_proxy' => ['required', 'string', 'max:500', new ValidOutboundProxyUrl], + 'outbound_proxy_bypass' => ['nullable', 'array'], + 'outbound_proxy_bypass.*' => ['string', 'max:255'], + ]; + + return HookManager::applyFilters('core.settings.test_outbound_proxy_validation_rules', $rules, $this); + } + + /** + * 검증 실패 메시지를 반환합니다. + * + * @return array + */ + public function messages(): array + { + return [ + 'outbound_proxy.required' => __('validation.settings.outbound_proxy_required'), + 'outbound_proxy.string' => __('validation.settings.outbound_proxy_string'), + 'outbound_proxy.max' => __('validation.settings.outbound_proxy_max'), + 'outbound_proxy_bypass.array' => __('validation.settings.outbound_proxy_bypass_array'), + 'outbound_proxy_bypass.*.string' => __('validation.settings.outbound_proxy_bypass_item_string'), + 'outbound_proxy_bypass.*.max' => __('validation.settings.outbound_proxy_bypass_item_max'), + ]; + } + + /** + * 검증 속성명을 반환합니다. + * + * @return array + */ + public function attributes(): array + { + return [ + 'outbound_proxy' => __('validation.attributes.outbound_proxy'), + 'outbound_proxy_bypass' => __('validation.attributes.outbound_proxy_bypass'), + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3a22a476..4975166c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -21,6 +21,7 @@ use Illuminate\Database\Events\QueryExecuted; use Illuminate\Http\Request; use Illuminate\Notifications\ChannelManager; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Schema; @@ -104,6 +105,9 @@ class AppServiceProvider extends ServiceProvider // SQL 쿼리 로그 설정 $this->configureSqlQueryLogging(); + // 아웃바운드 HTTP 프록시 설정 + $this->configureOutboundProxy(); + // 로그인 라우트 per-IP 백업 throttle — 보안 환경설정의 per-account 잠금과 2중 방어. // 존재하지 않는 계정에 대한 brute-force / 동일 IP 의 다른 계정 시도까지 차단. $this->configureLoginRateLimiter(); @@ -154,6 +158,29 @@ class AppServiceProvider extends ServiceProvider } } + /** + * 아웃바운드 HTTP 프록시를 설정합니다. + * + * 환경설정에 프록시가 지정되어 있으면 `Http::` 파사드로 나가는 모든 요청이 그 프록시를 + * 경유합니다. 결제 승인, 코어 업데이트 조회, GeoIP 내려받기, 알림 웹훅 등 확장이 보내는 + * 요청까지 함께 적용되므로, 확장 코드를 고치지 않고도 출발지 IP 를 바꿀 수 있습니다. + * + * 적용 여부 판정은 `App\Support\OutboundProxy` 가 소유하며, 이 메서드는 판정 결과만 + * 소비합니다 — 디버그 모드 게이트를 여기서 다시 검사하지 않는 이유입니다. + * + * 개별 요청이 `withOptions(['proxy' => ...])` 로 지정한 값은 전역 옵션보다 우선합니다. + */ + private function configureOutboundProxy(): void + { + $proxy = config('g7.outbound_proxy'); + + if (empty($proxy)) { + return; + } + + Http::globalOptions(['proxy' => $proxy]); + } + /** * SQL 쿼리 로깅을 설정합니다. * diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 05e37e67..3a3edecd 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Repositories\JsonConfigRepository; use App\Support\AllowedExtensions; use App\Support\ExtensionSettingsMirror; +use App\Support\OutboundProxy; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; use Predis\Client; @@ -274,6 +275,11 @@ class SettingsServiceProvider extends ServiceProvider if (isset($debugSettings['sql_query_log'])) { Config::set('g7.sql_query_log', (bool) $debugSettings['sql_query_log']); } + + // 아웃바운드 HTTP 프록시 설정. + // 적용 여부 판정은 OutboundProxy 가 단독으로 소유한다 — 디버그 모드가 꺼져 있으면 + // 저장값이 남아 있어도 null 이 되어 주입되지 않는다. + Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings)); } /** diff --git a/app/Rules/ValidOutboundProxyUrl.php b/app/Rules/ValidOutboundProxyUrl.php new file mode 100644 index 00000000..80c2261f --- /dev/null +++ b/app/Rules/ValidOutboundProxyUrl.php @@ -0,0 +1,47 @@ + implode(', ', OutboundProxy::ALLOWED_SCHEMES), + ])); + } + } +} diff --git a/app/Services/OutboundProxyTester.php b/app/Services/OutboundProxyTester.php new file mode 100644 index 00000000..53cdae46 --- /dev/null +++ b/app/Services/OutboundProxyTester.php @@ -0,0 +1,116 @@ + $bypass 프록시 예외 목록 + * @return array{success: bool, message_key: string, egress_ip: string|null, elapsed_ms: int, error: string|null} 검사 결과 + */ + public function test(string $proxyUrl, array $bypass = []): array + { + // 적용 시와 같은 조립·정규화를 거친다 — 여기서 손으로 배열을 만들면 저장 전에 + // 확인한 구성과 저장 후 실제로 적용되는 구성이 달라진다. + $proxyOptions = OutboundProxy::options($proxyUrl, $bypass); + + if ($proxyOptions === null) { + return $this->result(false, 'settings.outbound_proxy_test_invalid_url', null, 0); + } + + $urls = (array) config('core.outbound_proxy.egress_lookup_urls', []); + + if ($urls === []) { + return $this->result(false, 'settings.outbound_proxy_test_no_lookup_url', null, 0); + } + + $timeout = (int) config('core.outbound_proxy.test_timeout_seconds', 10); + $startedAt = microtime(true); + $lastError = null; + + foreach ($urls as $url) { + try { + $response = Http::withOptions(['proxy' => $proxyOptions]) + ->withHeaders(['User-Agent' => 'curl/8']) + ->timeout($timeout) + ->get($url); + + if (! $response->successful()) { + $lastError = 'HTTP '.$response->status(); + + continue; + } + + $ip = trim($response->body()); + + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + $lastError = 'unexpected response body'; + + continue; + } + + return $this->result(true, 'settings.outbound_proxy_test_success', $ip, $this->elapsedMs($startedAt)); + } catch (\Throwable $e) { + // 개별 조회처 실패는 다음 후보로 넘어간다 — 한 곳이 죽어 있다고 프록시가 + // 잘못됐다고 단정할 수 없기 때문이다. + $lastError = $e->getMessage(); + } + } + + Log::warning('아웃바운드 프록시 연결 테스트 실패', ['error' => $lastError]); + + return $this->result(false, 'settings.outbound_proxy_test_failed', null, $this->elapsedMs($startedAt), $lastError); + } + + /** + * 경과 시간을 밀리초로 환산합니다. + * + * @param float $startedAt 시작 시각 (microtime) + * @return int 경과 밀리초 + */ + private function elapsedMs(float $startedAt): int + { + return (int) round((microtime(true) - $startedAt) * 1000); + } + + /** + * 결과 배열을 구성합니다. + * + * @param bool $success 성공 여부 + * @param string $messageKey 다국어 메시지 키 + * @param string|null $egressIp 프록시를 거친 출발지 IP + * @param int $elapsedMs 경과 밀리초 + * @param string|null $error 실패 원인 원문 (관리자 진단용) + * @return array{success: bool, message_key: string, egress_ip: string|null, elapsed_ms: int, error: string|null} + */ + private function result(bool $success, string $messageKey, ?string $egressIp, int $elapsedMs, ?string $error = null): array + { + return [ + 'success' => $success, + 'message_key' => $messageKey, + 'egress_ip' => $egressIp, + 'elapsed_ms' => $elapsedMs, + 'error' => $error, + ]; + } +} diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php index 8ce26e7e..b9bdb70b 100644 --- a/app/Support/ApiDoc/ParameterDescriber.php +++ b/app/Support/ApiDoc/ParameterDescriber.php @@ -262,6 +262,8 @@ class ParameterDescriber 'cache_ttl' => '캐시 유효 시간 (초)', 'debug_mode' => '디버그 모드 사용 여부 (상세 오류 노출)', 'sql_query_log' => 'SQL 쿼리 로그 기록 여부', + 'outbound_proxy' => '외부 HTTP 호출이 경유할 프록시 주소 (디버그 모드에서만 적용)', + 'outbound_proxy_bypass' => '프록시를 경유하지 않을 호스트 목록', 'maintenance_mode' => '점검 모드 사용 여부 (사이트 접근 차단)', 'force_https' => 'HTTPS 강제 리다이렉트 여부', 'max_login_attempts' => '로그인 실패 허용 횟수 (초과 시 잠금)', diff --git a/app/Support/OutboundProxy.php b/app/Support/OutboundProxy.php new file mode 100644 index 00000000..cbfc8592 --- /dev/null +++ b/app/Support/OutboundProxy.php @@ -0,0 +1,191 @@ + $debugSettings debug 카테고리 설정 배열 + * @return array{http: string, https: string, no: array}|null 적용할 프록시 옵션 (미적용 시 null) + */ + public static function resolve(array $debugSettings): ?array + { + // 게이트: 디버그 모드가 꺼져 있으면 저장값이 있어도 적용하지 않는다. + if (empty($debugSettings['mode'])) { + return null; + } + + return self::options( + $debugSettings['outbound_proxy'] ?? null, + $debugSettings['outbound_proxy_bypass'] ?? [] + ); + } + + /** + * 프록시 주소와 예외 목록을 Guzzle 의 `proxy` 옵션 형태로 조립합니다. + * + * 게이트(디버그 모드)는 보지 않습니다 — 저장 전 연결 테스트처럼 아직 적용 대상이 아닌 + * 값을 그대로 검사해야 하는 경로가 있기 때문입니다. 게이트 판정은 `resolve()` 가 맡습니다. + * + * 조립을 이 한 곳에 모으는 이유는 정규화 때문입니다. 테스트가 손으로 배열을 만들면 예외 + * 목록의 공백·빈 항목·중복 처리가 실제 적용분과 어긋나, 저장 전에 확인한 구성과 저장 후 + * 적용되는 구성이 달라집니다. + * + * @param mixed $url 프록시 주소 + * @param mixed $bypass 예외 목록 + * @return array{http: string, https: string, no: array}|null 조립된 옵션 (주소가 부적합하면 null) + */ + public static function options(mixed $url, mixed $bypass = []): ?array + { + $normalized = self::normalizeUrl($url); + + if ($normalized === null) { + return null; + } + + return [ + 'http' => $normalized, + 'https' => $normalized, + 'no' => self::normalizeBypass($bypass), + ]; + } + + /** + * 프록시 URL 이 적용 가능한 형태인지 판정합니다. + * + * @param mixed $value 검사할 값 + * @return bool 허용 스킴과 호스트를 갖춘 URL 이면 true + */ + public static function isValidUrl(mixed $value): bool + { + return self::normalizeUrl($value) !== null; + } + + /** + * 현재 적용 중인 프록시를 curl 옵션 형태로 돌려줍니다. + * + * `Http::` 파사드를 쓰지 못하는 호출 지점(외부 SDK 규약상 curl 핸들을 직접 다뤄야 하는 + * 경우 등)이 같은 프록시를 타도록 하기 위한 통로입니다. 판정은 여기서 다시 하지 않고 + * 이미 주입된 `g7.outbound_proxy` 를 읽습니다 — 게이트는 한 곳에만 둔다. + * + * 적용 대상이 없으면 빈 배열이므로 `curl_setopt_array()` 에 그대로 넘겨도 무해합니다. + * + * @return array curl 옵션 배열 (미적용 시 빈 배열) + */ + public static function curlOptions(): array + { + $proxy = config('g7.outbound_proxy'); + + if (! is_array($proxy) || empty($proxy['https'])) { + return []; + } + + $options = [CURLOPT_PROXY => $proxy['https']]; + + if (! empty($proxy['no']) && is_array($proxy['no'])) { + $options[CURLOPT_NOPROXY] = implode(',', $proxy['no']); + } + + return $options; + } + + /** + * 프록시 URL 을 정규화합니다. + * + * 허용 스킴과 호스트를 모두 갖추지 못한 값은 null 을 돌려줍니다. + * + * @param mixed $value 원본 값 + * @return string|null 정규화된 URL (부적합 시 null) + */ + private static function normalizeUrl(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $url = trim($value); + + if ($url === '') { + return null; + } + + $parts = parse_url($url); + + if ($parts === false || empty($parts['host'])) { + return null; + } + + $scheme = strtolower($parts['scheme'] ?? ''); + + if (! in_array($scheme, self::ALLOWED_SCHEMES, true)) { + return null; + } + + return $url; + } + + /** + * 프록시 예외 목록을 정규화합니다. + * + * 빈 항목과 중복을 걸러내고 순번을 다시 매깁니다 — 비연속 키는 JSON 직렬화 시 객체가 되어 + * Guzzle 이 목록으로 읽지 못합니다. + * + * @param mixed $value 원본 예외 목록 + * @return array 정규화된 호스트 목록 + */ + private static function normalizeBypass(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $hosts = []; + + foreach ($value as $host) { + if (! is_string($host)) { + continue; + } + + $host = trim($host); + + if ($host !== '') { + $hosts[] = $host; + } + } + + return array_values(array_unique($hosts)); + } +} diff --git a/config/app.php b/config/app.php index ca5ebd49..ccc01849 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.7'), + 'version' => env('APP_VERSION', '7.1.0'), /* |-------------------------------------------------------------------------- diff --git a/config/core.php b/config/core.php index 3609bbef..b1b08c28 100644 --- a/config/core.php +++ b/config/core.php @@ -105,6 +105,27 @@ return [ 'max_page' => 1000, ], + /* + |-------------------------------------------------------------------------- + | 아웃바운드 프록시 연결 테스트 + |-------------------------------------------------------------------------- + | 운영자가 환경설정에 입력한 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 + | 나갔을 때 상대편에 어떤 IP 로 보이는지 확인하는 데 쓰는 조회 대상입니다. + | + | 출발지 IP 는 운영자가 결제사·외부 서비스에 등록해야 하는 값이라, 프록시를 켠 상태의 + | 실제 값을 알려주는 것이 이 테스트의 목적입니다. 목록은 순차 시도하며 먼저 유효한 + | IP 를 돌려준 곳에서 멈춥니다. 폐쇄망 등 외부 조회가 불가능한 환경에서는 목록을 + | 비워 두면 도달성만 확인하고 IP 는 보고하지 않습니다. + */ + 'outbound_proxy' => [ + 'egress_lookup_urls' => [ + 'https://api.ipify.org', + 'https://ifconfig.me/ip', + 'https://icanhazip.com', + ], + 'test_timeout_seconds' => 10, + ], + /* |-------------------------------------------------------------------------- | 검색 — DBMS 별 부분일치 연산자 diff --git a/config/settings/defaults.json b/config/settings/defaults.json index 30a388b7..dd001aef 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -102,7 +102,9 @@ "debug": { "mode": false, "sql_query_log": false, - "log_level": "error" + "log_level": "error", + "outbound_proxy": "", + "outbound_proxy_bypass": [] }, "core_update": { "github_url": "", @@ -270,7 +272,9 @@ "_comment": "debug 설정은 advanced 카테고리에 병합", "fields": { "mode": { "type": "boolean", "sensitive": false, "frontend_key": "debug_mode" }, - "sql_query_log": { "type": "boolean", "sensitive": false, "expose": false } + "sql_query_log": { "type": "boolean", "sensitive": false, "expose": false }, + "outbound_proxy": { "type": "string", "sensitive": true }, + "outbound_proxy_bypass": { "type": "array", "sensitive": false, "expose": false } } }, "core_update": { diff --git a/docs/backend/admin-settings-access.md b/docs/backend/admin-settings-access.md index a96fa95c..d26705c1 100644 --- a/docs/backend/admin-settings-access.md +++ b/docs/backend/admin-settings-access.md @@ -33,6 +33,8 @@ | `general.timezone` | `app.default_user_timezone` (`app.timezone` 아님) | | `general.language` | `app.locale` | | `debug.mode` | `app.debug`, `logging.*.level` | +| `debug.sql_query_log` | `g7.sql_query_log` | +| `debug.outbound_proxy`, `debug.outbound_proxy_bypass` | `g7.outbound_proxy` (디버그 모드 OFF 면 `null`) | | `drivers.cache_driver` | `cache.default` (testing 차단) | | `drivers.session_driver` | `session.driver` (testing 차단) | | `drivers.session_lifetime` | `session.lifetime` (testing 차단) | @@ -140,6 +142,45 @@ plugin_setting('sirsoft-pay_kginicis', 'api_key'); --- +## 설정이 여는 기능에 게이트가 필요한 경우 + +설정 하나가 위험한 동작을 여는 경우, 관리자 화면에서 입력칸을 조건부로 감추는 것은 게이트가 아니다. 저장 API 를 직접 호출하면 값은 그대로 저장되므로, 실질 게이트는 **그 값을 실제로 쓸지 판정하는 지점** 하나뿐이다. + +`debug.outbound_proxy` 가 그 예다. 지정된 프록시는 코어가 바깥으로 내보내는 모든 HTTP 요청(결제 승인, 코어 업데이트 조회, GeoIP 내려받기, 알림 웹훅)의 경로를 바꾸므로, 디버그 모드가 켜져 있을 때만 적용한다. + +| 구분 | 담당 | +|------|------| +| 판정 (SSoT) | `App\Support\OutboundProxy::resolve()` — 디버그 모드 OFF 면 저장값이 있어도 `null` | +| 조립 (SSoT) | `OutboundProxy::options()` — 주소·예외 목록 정규화. 저장 전 연결 테스트도 이 조립을 거친다 | +| 주입 | `SettingsServiceProvider::applyDebugConfig()` — 판정 결과를 `g7.outbound_proxy` 에 넣는다 | +| 적용 | `AppServiceProvider::configureOutboundProxy()` — `Http::globalOptions()` 에 실는다 | +| 화면 | 고급 탭의 조건부 렌더링 — 편의이며 게이트가 아니다 | + +주입·적용 지점은 게이트를 다시 검사하지 않는다. 같은 판정을 두 곳에 두면 한쪽만 바뀌었을 때 "저장은 되는데 적용되지 않는" 상태가 예외 없이 생긴다. + +저장 전 확인 기능(연결 테스트 등)이 있다면 그 경로도 같은 조립을 거쳐야 한다. 테스트가 값을 손으로 조립하면 정규화가 어긋나 운영자가 확인한 구성과 저장 후 적용되는 구성이 달라지는데, 두 구성 모두 정상 동작하므로 그 어긋남 자체는 아무 신호도 남기지 않는다. + +새 설정이 이런 성격이라면 같은 형태를 따른다 — 판정 함수 하나, 그 결과만 소비하는 주입·적용 지점, 그리고 디버그 모드 OFF 에서 미적용을 단언하는 회귀 테스트. + +### 적용 범위 — `Http::` 를 쓰지 않는 호출 + +`Http::globalOptions()` 는 `Http` 파사드가 만든 요청에만 걸린다. 같은 사이트 안에서도 아래는 갈린다. + +| 호출 방식 | 프록시 적용 | 비고 | +|---|---|---| +| `Http::get(...)` | 적용 | 코어·확장 구분 없이 자동 | +| `Http::withOptions([...])` (다른 옵션) | 적용 | `array_replace_recursive` 라 `proxy` 키는 보존된다 | +| `Http::withOptions(['proxy' => ...])` | 호출부 값 우선 | 의도된 우선순위 (연결 테스트가 이 경로를 쓴다) | +| `curl_*` 직접 | **미적용** | `OutboundProxy::curlOptions()` 를 `curl_setopt_array()` 에 넘겨 편입 | +| `new GuzzleHttp\Client()` 직접 | **미적용** | Laravel 팩토리를 거치지 않는다 | +| `fsockopen` / 원시 소켓 | **미적용** | 프로토콜상 프록시를 태우려면 별도 구현이 필요하다 | + +외부 연동 규약 때문에 `Http::` 를 쓸 수 없는 확장은 `OutboundProxy::curlOptions()` 를 쓴다. 판정은 코어가 하고 확장은 결과만 받으므로 게이트가 갈라지지 않으며, 미적용 상태에서는 빈 배열이라 그대로 넘겨도 무해하다. + +이 결함은 신호를 남기지 않는다 — 우회한 호출도 정상 성공하고, 상대편에 보이는 출발지 IP 만 달라진다. 외부 호출 지점을 새로 만들 때 어느 통로를 쓰는지 확인한다. + +--- + ## 관련 문서 - [service-provider.md](service-provider.md) — ServiceProvider 안전성 (DB 접근 가드) diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index 88cec79c..33bae599 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -240,6 +240,9 @@ HTTP/1.1 200 | advanced.seo_cache_ttl | body | integer | 아니오 | min 0, max 14400 | SEO 캐시 만료 시간 (초, 0 = 만료 없음) | | advanced.debug_mode | body | boolean | 아니오 | — | 디버그 모드 사용 여부 (상세 오류 노출) | | advanced.sql_query_log | body | boolean | 아니오 | — | SQL 쿼리 로그 기록 여부 | +| advanced.outbound_proxy | body | string | 아니오 | max 500, 스킴 `http`/`https`/`socks4`/`socks4a`/`socks5`/`socks5h` | 외부 HTTP 호출이 경유할 프록시 주소 (예: `socks5h://127.0.0.1:1080`). 빈 값이면 사용하지 않으며, 디버그 모드가 꺼져 있으면 저장되어도 적용되지 않는다 | +| advanced.outbound_proxy_bypass | body | array | 아니오 | — | 프록시를 경유하지 않을 호스트 목록 | +| advanced.outbound_proxy_bypass.* | body | string | 아니오 | max 255 | 프록시 예외 호스트 | | advanced.core_update_github_url | body | string | 아니오 | max 500 | 코어 업데이트를 확인할 GitHub 저장소 URL | | advanced.core_update_github_token | body | string | 아니오 | max 500 | 프라이빗 저장소의 코어/확장 업데이트에 사용할 GitHub 액세스 토큰 (공개 저장소는 비워둘 수 있음) | | advanced.geoip_enabled | body | boolean | 아니오 | — | IP 기반 타임존 감지(GeoIP) 사용 여부 | @@ -1250,6 +1253,79 @@ _단건 응답: `data` 객체의 필드 (DriverConnectionTester::testAll() 산 폼에 입력한 드라이버 접속 정보(S3·Redis·Memcached·Websocket 등)로 실제 연결을 시도해 결과를 반환합니다. 설정을 저장하기 전에 접속 정보가 유효한지 확인하는 용도입니다. 모든 테스트 통과 시 성공 메시지, 일부 실패 시에도 HTTP 성공 응답으로 항목별 결과(`all_passed=false` 포함)를 함께 반환합니다. +### POST /api/admin/settings/test-outbound-proxy + +- **라우트명**: `api.admin.settings.test-outbound-proxy` +- **컨트롤러**: `AppHttpControllersApiAdminSettingsController@testOutboundProxy` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| outbound_proxy | body | string | 예 | max 500, 스킴 `http`/`https`/`socks4`/`socks4a`/`socks5`/`socks5h` | 검사할 프록시 주소 | +| outbound_proxy_bypass | body | array | 아니오 | — | 프록시를 경유하지 않을 호스트 목록 | +| outbound_proxy_bypass.* | body | string | 아니오 | max 255 | 프록시 예외 호스트 | + +**요청 예시** + +```http +POST /api/admin/settings/test-outbound-proxy HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "outbound_proxy": "socks5h://127.0.0.1:1080", + "outbound_proxy_bypass": ["internal.example.com"] +} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| success | boolean | `true` | 프록시를 거쳐 외부에 도달했는지 여부. `false` 여도 HTTP 200 으로 응답한다 — 연결 실패는 요청 처리 실패가 아니라 진단 결과다 | +| egress_ip | string|null | `203.0.113.9` | 프록시를 거쳤을 때 상대편에 보이는 출발지 IP. 외부 서비스에 등록할 값이며 실패 시 `null` | +| elapsed_ms | integer | `512` | 검사에 걸린 시간 (밀리초) | +| error | string|null | `cURL error 7: Failed to connect` | 실패 시에만 채워지는 원인 원문 (관리자 진단용) | + +**응답 예시** + +```json +{ + "success": true, + "message": "프록시 연결에 성공했습니다. 외부 서비스에는 이 IP 로 보입니다.", + "data": { + "success": true, + "message_key": "settings.outbound_proxy_test_success", + "egress_ip": "203.0.113.9", + "elapsed_ms": 512, + "error": null + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 프록시 주소가 비어 있거나 허용 스킴이 아닌 경우 | + + + +**설명** + +입력한 프록시로 외부에 연결해 보고, 성공하면 그 프록시를 거쳤을 때 상대편에 보이는 출발지 IP 를 함께 반환합니다. 이 IP 는 운영자가 결제사·외부 서비스의 허용 목록에 등록해야 하는 값이라, 설정을 저장하기 전에 확인할 수 있도록 제공합니다. + +검사 대상은 **이번 요청이 제출한 값**입니다. 저장된 설정이나 전역 프록시 옵션을 보지 않으므로, 저장 전에도 그대로 확인할 수 있고 이 호출이 다른 요청의 경로를 바꾸지도 않습니다. + +조회 대상은 `config('core.outbound_proxy.egress_lookup_urls')` 가 소유하며 순차 시도합니다. 목록을 비우면 도달성만 확인하고 IP 는 보고하지 않습니다(폐쇄망 대응). + + ### POST /api/admin/settings/test-mail - **라우트명**: `api.admin.settings.test-mail` diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 7caf1bb9..a9c6bc09 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/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.7] - 2026-08-20 + +### Added + +- 아웃바운드 프록시 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`settings.outbound_proxy_*`, `attributes.outbound_proxy*`). +- 아웃바운드 프록시 연결 테스트 결과 안내 문구의 일본어 번역을 추가했습니다 (`settings.outbound_proxy_test_*`). + ## [1.0.6] - 2026-08-19 ### Changed diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php index d6cc8a53..9c2832bc 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php @@ -76,6 +76,12 @@ return [ 'driver_test_partial' => '一部のドライバ接続テストが失敗しました。', 'driver_test_error' => 'ドライバ接続テスト中にエラーが発生しました。', 'unknown_driver' => '不明なドライバです。', + + // アウトバウンドプロキシ接続テストメッセージ + 'outbound_proxy_test_success' => 'プロキシ接続に成功しました。外部サービスにはこの IP アドレスとして見えます。', + 'outbound_proxy_test_failed' => 'プロキシ経由で接続できませんでした。アドレスとプロキシサーバーの状態を確認してください。', + 'outbound_proxy_test_invalid_url' => 'プロキシアドレスの形式が正しくありません。', + 'outbound_proxy_test_no_lookup_url' => '送信元 IP の照会先が設定されていないため確認できません。', 's3_test_success' => 'S3バケットに正常に接続されました。', 's3_test_failed' => 'S3バケットへの接続に失敗しました。', 's3_missing_config' => 'S3設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index a0a08028..92ca8a3c 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -898,6 +898,15 @@ return [ 'debug_mode_boolean' => 'デバッグモードはtrueまたはfalse値である必要があります。', 'sql_query_log_required' => 'SQLクエリログ設定を選択してください。', 'sql_query_log_boolean' => 'SQLクエリログはtrueまたはfalse値である必要があります。', + + // アウトバウンド HTTP プロキシ + 'outbound_proxy_required' => 'アウトバウンドプロキシアドレスを入力してください。', + 'outbound_proxy_string' => 'アウトバウンドプロキシアドレスは文字列である必要があります。', + 'outbound_proxy_max' => 'アウトバウンドプロキシアドレスは:max文字を超えることはできません。', + 'outbound_proxy_invalid' => 'アウトバウンドプロキシアドレスの形式が正しくありません。使用可能な形式: :schemes (例: socks5h://127.0.0.1:1080)', + 'outbound_proxy_bypass_array' => 'プロキシ除外リストは配列である必要があります。', + 'outbound_proxy_bypass_item_string' => 'プロキシ除外項目は文字列である必要があります。', + 'outbound_proxy_bypass_item_max' => 'プロキシ除外項目は:max文字を超えることはできません。', 'core_update_github_url_invalid' => 'GitHubリポジトリURLの形式が正しくありません。', 'core_update_github_url_max' => 'GitHubリポジトリURLは500字を超えることはできません。', 'core_update_github_token_max' => 'GitHubアクセストークンは500字を超えることはできません。', @@ -1161,6 +1170,8 @@ return [ 'seo_sitemap_cache_ttl' => 'SEO サイトマップキャッシュ保持時間', 'debug_mode' => 'デバッグモード', 'sql_query_log' => 'SQL クエリログ', + 'outbound_proxy' => 'アウトバウンドプロキシアドレス', + 'outbound_proxy_bypass' => 'プロキシ除外リスト', 'core_update_github_url' => 'コア更新 GitHub アドレス', 'core_update_github_token' => 'コア更新 GitHub トークン', 'geoip_enabled' => 'GeoIP の使用', diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index f904c3e6..e7ad4030 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "scope": "core", "target_identifier": null, 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 a957a097..0b64535e 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.6] - 2026-08-20 + +### Added + +- 환경설정 > 고급의 아웃바운드 프록시 설정 항목명·설명·입력 안내와 연결 테스트 버튼 라벨의 일본어 번역을 추가했습니다. + ## [1.0.5] - 2026-08-19 ### 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 231be2e9..1fcd76de 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 @@ -1786,6 +1786,13 @@ "dev_dashboard": "開発ダッシュボード", "sql_query_log": "SQLクエリログ", "sql_query_log_desc": "実行されたSQLクエリをログに記録します。ログファイルの場所: /storage/logs/query.log", + "outbound_proxy": "アウトバウンドプロキシアドレス", + "outbound_proxy_desc": "サイトが外部に送信するすべてのリクエスト(決済承認、コア更新確認、通知送信など)がこのサーバーを経由します。接続 IP を制限する外部サービスと連携する際に使用します。空欄の場合は使用しません。", + "outbound_proxy_placeholder": "socks5h://127.0.0.1:1080", + "outbound_proxy_bypass": "プロキシ除外リスト", + "outbound_proxy_bypass_desc": "このリストにあるアドレスへのリクエストはプロキシを経由せず直接送信されます。内部ネットワークのアドレスを登録すると不要な迂回を減らせます。", + "outbound_proxy_bypass_placeholder": "アドレスを入力して Enter", + "outbound_proxy_test": "接続テスト", "core_update": "アップデート設定", "core_update_desc": "コアおよび拡張(モジュール·プラグイン·テンプレート)アップデートで使用するGitHub認証情報を設定します。", "core_update_github_url": "GitHubリポジトリURL", 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 063f3b9c..a69f93ef 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.5", + "version": "1.0.6", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-admin_basic", diff --git a/lang/en/settings.php b/lang/en/settings.php index 8b3f2797..51d403ae 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -86,6 +86,12 @@ return [ 'driver_test_error' => 'An error occurred while testing driver connections.', 'unknown_driver' => 'Unknown driver.', + // Outbound proxy connection test messages + 'outbound_proxy_test_success' => 'Connected through the proxy. External services will see this IP address.', + 'outbound_proxy_test_failed' => 'Could not connect through the proxy. Check the address and the proxy server status.', + 'outbound_proxy_test_invalid_url' => 'The proxy address format is invalid.', + 'outbound_proxy_test_no_lookup_url' => 'No egress IP lookup target is configured, so the address could not be determined.', + // S3 test messages 's3_test_success' => 'Successfully connected to S3 bucket.', 's3_test_failed' => 'Failed to connect to S3 bucket.', diff --git a/lang/en/validation.php b/lang/en/validation.php index 7305d75d..7e8e554e 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -913,6 +913,15 @@ return [ 'sql_query_log_required' => 'Please select the SQL query log setting.', 'sql_query_log_boolean' => 'SQL query log must be true or false.', + // Outbound HTTP proxy + 'outbound_proxy_required' => 'Please enter the outbound proxy address.', + 'outbound_proxy_string' => 'The outbound proxy address must be a string.', + 'outbound_proxy_max' => 'The outbound proxy address may not be greater than :max characters.', + 'outbound_proxy_invalid' => 'The outbound proxy address format is invalid. Supported schemes: :schemes (e.g. socks5h://127.0.0.1:1080)', + 'outbound_proxy_bypass_array' => 'The proxy bypass list must be an array.', + 'outbound_proxy_bypass_item_string' => 'Each proxy bypass entry must be a string.', + 'outbound_proxy_bypass_item_max' => 'Each proxy bypass entry may not be greater than :max characters.', + // List limits 'pagination_result_cap_integer' => 'The total count cap must be a number.', 'pagination_result_cap_min' => 'The total count cap must be at least :min. (0 = unlimited)', @@ -1307,6 +1316,8 @@ return [ 'seo_sitemap_cache_ttl' => 'SEO sitemap cache lifetime', 'debug_mode' => 'debug mode', 'sql_query_log' => 'SQL query log', + 'outbound_proxy' => 'outbound proxy address', + 'outbound_proxy_bypass' => 'proxy bypass list', 'core_update_github_url' => 'core update GitHub URL', 'core_update_github_token' => 'core update GitHub token', 'geoip_enabled' => 'GeoIP', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 7480e8ed..4f884136 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -86,6 +86,12 @@ return [ 'driver_test_error' => '드라이버 연결 테스트 중 오류가 발생했습니다.', 'unknown_driver' => '알 수 없는 드라이버입니다.', + // 아웃바운드 프록시 연결 테스트 메시지 + 'outbound_proxy_test_success' => '프록시 연결에 성공했습니다. 외부 서비스에는 이 IP 로 보입니다.', + 'outbound_proxy_test_failed' => '프록시로 연결하지 못했습니다. 주소와 프록시 서버 상태를 확인해주세요.', + 'outbound_proxy_test_invalid_url' => '프록시 주소 형식이 올바르지 않습니다.', + 'outbound_proxy_test_no_lookup_url' => '출발지 IP 조회 대상이 설정되어 있지 않아 확인할 수 없습니다.', + // S3 테스트 메시지 's3_test_success' => 'S3 버킷에 성공적으로 연결되었습니다.', 's3_test_failed' => 'S3 버킷 연결에 실패했습니다.', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index ce529839..78fa94a4 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -1000,6 +1000,15 @@ return [ 'sql_query_log_required' => 'SQL 쿼리 로그 설정을 선택해주세요.', 'sql_query_log_boolean' => 'SQL 쿼리 로그는 true 또는 false 값이어야 합니다.', + // 아웃바운드 HTTP 프록시 + 'outbound_proxy_required' => '아웃바운드 프록시 주소를 입력해주세요.', + 'outbound_proxy_string' => '아웃바운드 프록시 주소는 문자열이어야 합니다.', + 'outbound_proxy_max' => '아웃바운드 프록시 주소는 :max자를 초과할 수 없습니다.', + 'outbound_proxy_invalid' => '아웃바운드 프록시 주소 형식이 올바르지 않습니다. 사용 가능한 형식: :schemes (예: socks5h://127.0.0.1:1080)', + 'outbound_proxy_bypass_array' => '프록시 예외 목록은 배열이어야 합니다.', + 'outbound_proxy_bypass_item_string' => '프록시 예외 항목은 문자열이어야 합니다.', + 'outbound_proxy_bypass_item_max' => '프록시 예외 항목은 :max자를 초과할 수 없습니다.', + // 목록 한계값 'pagination_result_cap_integer' => '총 건수 집계 상한은 숫자여야 합니다.', 'pagination_result_cap_min' => '총 건수 집계 상한은 :min 이상이어야 합니다. (0 = 무제한)', @@ -1300,6 +1309,8 @@ return [ 'seo_sitemap_cache_ttl' => 'SEO 사이트맵 캐시 유지시간', 'debug_mode' => '디버그 모드', 'sql_query_log' => 'SQL 쿼리 로그', + 'outbound_proxy' => '아웃바운드 프록시 주소', + 'outbound_proxy_bypass' => '프록시 예외 목록', 'core_update_github_url' => '코어 업데이트 GitHub 주소', 'core_update_github_token' => '코어 업데이트 GitHub 토큰', 'geoip_enabled' => 'GeoIP 사용', diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index 5b3a6acc..f83bc8f0 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed +- 코어 최소 요구 버전을 7.1.0 로 상향했습니다. - 에스크로 배송등록 오류 안내가 다국어로 제공됩니다. - 결제창 닫힘 보고 사유의 길이 제한을 다른 결제 플러그인과 동일하게 160자로 통일했습니다. - 해외결제(CBT) 해시 요청에서 체크아웃 토큰 누락 응답이 표준 검증 응답(422)으로 통일되었습니다. @@ -19,6 +20,7 @@ ### Fixed +- CBT 연결 점검이 서버 출발지 IP 와 호스트 연결 가능 여부를 사이트 환경설정의 아웃바운드 프록시 설정을 따라 확인하도록 했습니다. 이전에는 프록시를 사용하는 환경에서도 프록시를 거치지 않고 확인해, 결제사에 등록해야 할 IP 와 다른 값을 알려주고 실제로는 결제가 되는 상황에서도 "연결 불가" 로 보고했습니다. - 플러그인 설정 화면의 안내·상태 아이콘이 의도한 크기와 다르게 보이던 문제와, 해외결제(CBT) 연결 상태 표시에 일부 스타일이 적용되지 않던 문제를 수정했습니다. - 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다. - 플러그인 설정 저장과 관리자 주문 결제 조회의 해외결제 대사 재시도·편의점 결제 처리가 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다. diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md index 52901f52..8a368aa9 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/cbt.md @@ -42,9 +42,9 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| egress_ip | string | `210.90.128.2` | 서버가 외부 통신 시 사용하는 egress IP. KG 이니시스 측에 DEVCBT 접근용 IP 화이트리스트 등록을 요청할 때 알려줄 IP이며, 외부 echo 서비스(ipify 등)를 순차 조회해 얻는다(모두 실패 시 null). | +| egress_ip | string | `210.90.128.2` | 서버가 외부 통신 시 사용하는 egress IP. KG 이니시스 측에 DEVCBT 접근용 IP 화이트리스트 등록을 요청할 때 알려줄 IP이며, 외부 echo 서비스(ipify 등)를 순차 조회해 얻는다(모두 실패 시 null). 사이트 환경설정에 아웃바운드 프록시가 적용되어 있으면 그 프록시를 거쳐 조회하므로, 실제 결제 요청과 같은 경로의 IP 가 반환된다. | | server_ip | string | `127.0.0.1` | `$_SERVER['SERVER_ADDR']` 로 읽은 서버 내부 IP. egress IP와 대조해 NAT/프록시 여부를 가늠하는 참고값이다. | -| hosts | array | `[{"name":"devcbt.inicis.com","env":"test","dns_resolved_i…` | 진단 대상 호스트별 결과 배열. 각 항목은 호스트명(`devcbt.inicis.com`), 환경(`test`), DNS 해석 IP(`dns_resolved_ip`), TCP 443 도달 여부(`tcp_443_reachable`)와 에러·응답지연(`tcp_443_error`, `tcp_443_latency_ms`)을 담는다. 운영계(`cbt.inicis.com`)는 화이트리스트 제약이 없어 제외된다. | +| hosts | array | `[{"name":"devcbt.inicis.com","env":"test","dns_resolved_i…` | 진단 대상 호스트별 결과 배열. 각 항목은 호스트명(`devcbt.inicis.com`), 환경(`test`), DNS 해석 IP(`dns_resolved_ip`), TCP 443 도달 여부(`tcp_443_reachable`)와 에러·응답지연(`tcp_443_error`, `tcp_443_latency_ms`)을 담는다. 운영계(`cbt.inicis.com`)는 화이트리스트 제약이 없어 제외된다. 도달 여부는 연결만 수행하고 데이터는 주고받지 않으며, 사이트 환경설정에 아웃바운드 프록시가 적용되어 있으면 그 프록시를 거쳐 확인한다 — 실제 결제 요청이 지나는 경로와 같은 경로를 재기 위함이다. | | callback | object | `{"app_url":"https:\/\/g7.dev","callback_url":"https:\/\/g…` | 결제 콜백 URL 진단 정보. 앱 URL·콜백 URL과 각각의 HTTPS 여부(`app_url_https`, `callback_url_https`)·공인 호스트 여부(`app_url_public`, `callback_url_public`), 그리고 콜백 호스트가 앱 URL 호스트와 일치하는지(`host_matches_app_url`)를 담아 CBT 콜백 수신 가능 여부를 점검한다. | **응답 예시** diff --git a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json index f0e400de..1f78ad77 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json @@ -13,7 +13,7 @@ "ko": "KG 이니시스 결제 게이트웨이 (표준결제창 연동, 일본결제 지원)", "en": "KG Inicis payment gateway (standard payment window, Japan payment support)" }, - "g7_version": ">=7.0.5", + "g7_version": ">=7.1.0", "dependencies": { "modules": { "sirsoft-ecommerce": ">=1.1.0" diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php index 8f8693ff..d962e105 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/AdminCbtConnectivityCheckController.php @@ -6,6 +6,7 @@ namespace Plugins\Sirsoft\PayKginicis\Controllers; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; +use App\Support\OutboundProxy; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; @@ -114,6 +115,7 @@ class AdminCbtConnectivityCheckController extends AdminBaseController return $ip; } } + return null; } @@ -129,6 +131,11 @@ class AdminCbtConnectivityCheckController extends AdminBaseController CURLOPT_TIMEOUT => self::EGRESS_LOOKUP_TIMEOUT, CURLOPT_FOLLOWLOCATION => false, ]); + + // 코어 환경설정의 아웃바운드 프록시를 이 조회에도 적용한다. + // 이 값은 운영자가 이니시스에 등록할 IP 이므로 실제 결제 호출과 같은 경로로 나가야 + // 한다 — 프록시를 켠 상태에서 직접 조회하면 등록해야 할 IP 와 다른 값을 보고한다. + curl_setopt_array($ch, OutboundProxy::curlOptions()); $body = curl_exec($ch); curl_close($ch); @@ -136,31 +143,59 @@ class AdminCbtConnectivityCheckController extends AdminBaseController return null; } $body = trim($body); + return filter_var($body, FILTER_VALIDATE_IP) !== false ? $body : null; } /** * TCP 443 연결 가능 여부 확인. * + * 연결만 수행하고 데이터는 주고받지 않는다(`CURLOPT_CONNECT_ONLY`). 원시 소켓(`fsockopen`) + * 대신 curl 을 쓰는 이유는 사이트 환경설정의 아웃바운드 프록시를 이 검사에도 태우기 + * 위해서다 — 프록시 핸드셰이크(HTTP CONNECT / SOCKS)는 curl 이 처리한다. + * + * 이 구분은 진단의 정확성을 좌우한다. 프록시를 쓰는 환경에서 원시 소켓으로 직접 확인하면 + * 실제 결제 요청이 지나는 경로가 아닌 곳을 재는 셈이라, 결제는 정상 동작하는데 진단만 + * "연결 불가" 로 보고하는 상태가 된다. + * + * @param string $host 검사 대상 호스트 * @return array{reachable: bool, error: ?string, latency_ms: ?int} */ private function checkTcp443(string $host): array { $start = microtime(true); - $errno = 0; - $errstr = ''; - $fp = @fsockopen($host, 443, $errno, $errstr, self::TCP_TIMEOUT_SECONDS); - $latencyMs = (int) round((microtime(true) - $start) * 1000); - if ($fp === false) { + $ch = curl_init('https://'.$host); + + if ($ch === false) { return [ 'reachable' => false, - 'error' => $errstr !== '' ? $errstr : 'connect failed', + 'error' => 'connect failed', + 'latency_ms' => 0, + ]; + } + + curl_setopt_array($ch, [ + CURLOPT_CONNECT_ONLY => true, + CURLOPT_CONNECTTIMEOUT => self::TCP_TIMEOUT_SECONDS, + CURLOPT_TIMEOUT => self::TCP_TIMEOUT_SECONDS, + ]); + curl_setopt_array($ch, OutboundProxy::curlOptions()); + + $connected = curl_exec($ch); + $error = curl_error($ch); + curl_close($ch); + + $latencyMs = (int) round((microtime(true) - $start) * 1000); + + if ($connected === false) { + return [ + 'reachable' => false, + 'error' => $error !== '' ? $error : 'connect failed', 'latency_ms' => $latencyMs, ]; } - fclose($fp); return [ 'reachable' => true, 'error' => null, diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md index 6dc4c422..e12bdeab 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.4] - 2026-08-20 + +### Changed + +- 본인인증 승인 요청이 사이트 환경설정의 아웃바운드 프록시 설정을 따르도록 했습니다. 이전에는 이 요청만 프록시를 거치지 않고 직접 나가서, 프록시를 사용하는 환경에서 결제사에 등록한 IP 와 다른 IP 로 접속했습니다. +- 코어 최소 요구 버전을 7.1.0 로 상향했습니다. + ## [1.0.3] - 2026-08-19 ### Fixed diff --git a/plugins/_bundled/sirsoft-verification_kginicis/composer.json b/plugins/_bundled/sirsoft-verification_kginicis/composer.json index d3f8bb8c..edc52d24 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/composer.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-verification_kginicis", "description": "KG Inicis Identity Verification provider for G7", - "version": "1.0.3", + "version": "1.0.4", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/package.json b/plugins/_bundled/sirsoft-verification_kginicis/package.json index 65ee40f1..29db9ea3 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/package.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-verification_kginicis", - "version": "1.0.3", + "version": "1.0.4", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json index 4a16bbf1..041c39ec 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json @@ -5,13 +5,13 @@ "ko": "KG이니시스 본인인증", "en": "KG Inicis Identity Verification" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "description": { "ko": "KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인", "en": "KG Inicis Identity Verification (reqSvcCd=03) provider for G7 core IDV infrastructure" }, - "g7_version": ">=7.0.6", + "g7_version": ">=7.1.0", "dependencies": { "modules": {}, "plugins": {} diff --git a/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php b/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php index 8bd0cc39..2b361c01 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php +++ b/plugins/_bundled/sirsoft-verification_kginicis/src/Services/InicisGateway.php @@ -2,6 +2,7 @@ namespace Plugins\Sirsoft\VerificationKginicis\Services; +use App\Support\OutboundProxy; use App\Support\OutboundUrlValidator; use Illuminate\Support\Str; use Plugins\Sirsoft\VerificationKginicis\Exceptions\DecryptException; @@ -141,6 +142,12 @@ class InicisGateway implements InicisGatewayInterface curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + // 코어 환경설정의 아웃바운드 프록시를 이 호출에도 적용한다. + // 본인인증 승인 요청은 이니시스가 가맹점 서버 IP 를 화이트리스트로 제한하는 + // 대상이라, 코어의 다른 외부 호출과 같은 IP 로 나가야 한다. 적용 여부 판정은 + // 코어가 소유하며 미적용 시 빈 배열이라 그대로 넘겨도 무해하다. + curl_setopt_array($ch, OutboundProxy::curlOptions()); + $responseBody = curl_exec($ch); $httpStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErrno = curl_errno($ch); diff --git a/routes/api.php b/routes/api.php index a64b4d81..6f77af6d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -632,6 +632,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin' Route::post('restore', [AdminSettingsController::class, 'restore'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.restore'); Route::post('test-mail', [AdminSettingsController::class, 'testMail'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-mail'); Route::post('test-driver', [AdminSettingsController::class, 'testDriverConnection'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-driver'); + Route::post('test-outbound-proxy', [AdminSettingsController::class, 'testOutboundProxy'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.test-outbound-proxy'); Route::post('geoip/update', [AdminGeoIpController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.geoip.update'); Route::get('{key}', [AdminSettingsController::class, 'show'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.show'); Route::put('{key}', [AdminSettingsController::class, 'update'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.update'); diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 072e5e09..d7b739fc 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.6] - 2026-08-20 + +### Added + +- 환경설정 > 고급 > 디버그 카드에 아웃바운드 프록시 설정이 추가되었습니다 — 디버그 모드를 켜면 사이트가 외부로 보내는 요청이 거쳐 갈 프록시 주소와, 프록시를 거치지 않을 주소 목록을 입력할 수 있습니다. +- 프록시 주소 옆에 「연결 테스트」 버튼이 추가되었습니다 — 저장하기 전에 연결 여부를 확인하고, 성공 시 외부 서비스에 보이는 IP 주소를 함께 표시합니다. + ## [1.0.5] - 2026-08-19 ### Added diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx new file mode 100644 index 00000000..09d7e06e --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx @@ -0,0 +1,203 @@ +/** + * @file admin-settings-outbound-proxy-visibility.test.tsx + * @description 아웃바운드 프록시 입력칸의 디버그 모드 조건부 노출 테스트 + * + * 프록시는 코어가 바깥으로 내보내는 모든 요청의 경로를 바꾼다. 그래서 입력칸은 디버그 모드가 + * 켜진 상태에서만 드러나야 한다. 다만 화면의 조건부 렌더링은 편의이지 게이트가 아니다 — + * 실제 차단은 서버측 판정(App\Support\OutboundProxy)이 맡고, 이 테스트는 화면이 그 의도와 + * 어긋나지 않는지만 고정한다. + * + * 디버그 모드 OFF 케이스에서 SQL 쿼리 로그 토글이 함께 렌더되는 것을 먼저 확인한다. + * 그 확인이 없으면 "아직 렌더되지 않아서 없는 것" 과 "조건에 걸려 없는 것" 이 구분되지 않는다. + */ + +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +const advancedPartial = JSON.parse( + readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_tab_advanced.json'), 'utf-8') +); + +// --------------------------------------------------------------------------- +// 테스트용 컴포넌트 +// --------------------------------------------------------------------------- + +const TestDiv: React.FC = ({ className, children }) =>

{children}
; +const TestInput: React.FC = ({ name, type }) => ; +const TestToggle: React.FC = ({ name }) => ( + +); +const TestTagInput: React.FC = ({ name }) =>
; +const TestButton: React.FC = ({ children, text, disabled }) => ( + +); +const TestA: React.FC = ({ children, text }) => {children || text}; +const TestSpan: React.FC = ({ children, text }) => {children || text}; +const TestP: React.FC = ({ children, text }) =>

{children || text}

; +const TestH3: React.FC = ({ children, text }) =>

{children || text}

; +const TestFragment: React.FC = ({ children }) => <>{children}; + +/** + * 테스트용 컴포넌트 레지스트리를 구성합니다. + * + * @returns 구성된 레지스트리 + */ +function setupTestRegistry(): ComponentRegistry { + const registry = ComponentRegistry.getInstance(); + + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } }, + Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } }, + TagInput: { component: TestTagInput, metadata: { name: 'TagInput', type: 'composite' } }, + A: { component: TestA, metadata: { name: 'A', type: 'basic' } }, + Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + P: { component: TestP, metadata: { name: 'P', type: 'basic' } }, + H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; + + return registry; +} + +/** + * id 로 노드를 깊이 우선 탐색합니다. + * + * @param node 탐색 시작 노드 + * @param id 찾을 노드 id + * @returns 찾은 노드 또는 null + */ +function findNodeById(node: any, id: string): any { + if (!node || typeof node !== 'object') return null; + if (node.id === id) return node; + for (const child of node.children ?? []) { + const found = findNodeById(child, id); + if (found) return found; + } + return null; +} + +/** + * partial 루트들에서 id 노드를 찾습니다. + * + * @param id 찾을 노드 id + * @returns 찾은 노드 또는 null + */ +function findInPartial(id: string): any { + for (const root of advancedPartial.components ?? [advancedPartial]) { + const found = findNodeById(root, id); + if (found) return found; + } + return null; +} + +/** + * 주어진 폼 상태로 디버그 설정 카드를 렌더합니다. + * + * @param advanced 폼의 advanced 하위 상태 + * @returns 레이아웃 테스트 유틸 + */ +function renderDebugCard(advanced: Record) { + const card = findInPartial('card_debug_settings'); + expect(card).not.toBeNull(); + + return createLayoutTest( + { + version: '1.0.0', + layout_name: 'test_outbound_proxy_visibility', + components: [card], + } as any, + { + initialState: { + _local: { + form: { advanced }, + errors: {}, + }, + }, + } + ); +} + +describe('아웃바운드 프록시 입력칸 노출 조건', () => { + let registry: ComponentRegistry; + + beforeEach(() => { + registry = setupTestRegistry(); + }); + + afterEach(() => { + (registry as any).registry = {}; + }); + + // @scenario debug_mode=on, proxy_value=empty, bypass_list=empty + // @effects proxy_inputs_visible_when_debug_mode_on + it('디버그 모드가 켜져 있으면 프록시 주소와 예외 목록이 렌더된다', async () => { + const testUtils = renderDebugCard({ debug_mode: true, sql_query_log: false }); + await testUtils.render(); + + expect(screen.getByTestId('advanced.outbound_proxy')).toBeInTheDocument(); + expect(screen.getByTestId('tags-advanced.outbound_proxy_bypass')).toBeInTheDocument(); + + testUtils.cleanup(); + }); + + // @scenario debug_mode=off, proxy_value=valid, bypass_list=empty + // @effects proxy_inputs_hidden_when_debug_mode_off + it('디버그 모드가 꺼져 있으면 프록시 입력칸이 렌더되지 않는다', async () => { + const testUtils = renderDebugCard({ debug_mode: false, sql_query_log: false }); + await testUtils.render(); + + // 카드 자체는 렌더됐음을 먼저 확정한다 — 그래야 아래 부재 단언이 의미를 갖는다. + expect(screen.getByTestId('toggle-advanced.sql_query_log')).toBeInTheDocument(); + + expect(screen.queryByTestId('advanced.outbound_proxy')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tags-advanced.outbound_proxy_bypass')).not.toBeInTheDocument(); + + testUtils.cleanup(); + }); + + // @scenario debug_mode=on, proxy_value=valid, bypass_list=empty + // @effects proxy_inputs_visible_when_debug_mode_on + it('레이아웃이 참조하는 폼 필드명이 서버 저장 키와 일치한다', () => { + const block = findInPartial('outbound_proxy_settings'); + expect(block).not.toBeNull(); + + const names: string[] = []; + const collect = (node: any) => { + if (!node || typeof node !== 'object') return; + if (node.props?.name) names.push(node.props.name); + for (const child of node.children ?? []) collect(child); + }; + collect(block); + + expect(names).toContain('advanced.outbound_proxy'); + expect(names).toContain('advanced.outbound_proxy_bypass'); + }); + // @scenario debug_mode=on, proxy_value=valid, bypass_list=empty + // @effects proxy_connection_test_button_wired + it('연결 테스트 버튼이 제출값을 실어 테스트 엔드포인트를 호출하도록 배선되어 있다', () => { + const block = findInPartial('btn_test_outbound_proxy'); + expect(block).not.toBeNull(); + + const apiCall = (block.actions ?? []).find((a: any) => a.handler === 'apiCall'); + expect(apiCall).toBeDefined(); + expect(apiCall.target).toBe('/api/admin/settings/test-outbound-proxy'); + expect(apiCall.params.method).toBe('POST'); + + // 저장된 설정이 아니라 입력창의 현재 값을 보내야 저장 전 확인이 성립한다. + expect(apiCall.params.body.outbound_proxy).toContain('_local.form?.advanced?.outbound_proxy'); + expect(apiCall.params.body.outbound_proxy_bypass).toContain('_local.form?.advanced?.outbound_proxy_bypass'); + + // 응답 후 로딩 해제가 성공/실패 양쪽에 걸려 있어야 버튼이 잠긴 채 남지 않는다. + for (const branch of ['onSuccess', 'onError']) { + const setState = (apiCall[branch] ?? []).find((a: any) => a.handler === 'setState'); + expect(setState, branch).toBeDefined(); + expect(setState.params.outboundProxyTesting, branch).toBe(false); + } + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json b/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json index 45ad464a..c0d1c358 100644 --- a/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json +++ b/templates/_bundled/sirsoft-admin_basic/editor-spec/sampleData.json @@ -13902,6 +13902,8 @@ "debug_mode": true, "sql_query_log": false, "log_level": "error", + "outbound_proxy": "", + "outbound_proxy_bypass": [], "core_update_github_url": "https://github.com/gnuboard/g7", "core_update_github_token": null, "geoip_enabled": false, @@ -13928,7 +13930,9 @@ "debug": { "debug_mode": true, "sql_query_log": false, - "log_level": "error" + "log_level": "error", + "outbound_proxy": "", + "outbound_proxy_bypass": [] }, "drivers": { "storage_driver": "local", 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 345b8e39..3daac052 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1782,6 +1782,13 @@ "dev_dashboard": "Dev Dashboard", "sql_query_log": "SQL Query Log", "sql_query_log_desc": "Log executed SQL queries. Log file location: /storage/logs/query.log", + "outbound_proxy": "Outbound proxy address", + "outbound_proxy_desc": "Every request this site sends out (payment approvals, core update checks, notifications) goes through this server. Use it when an external service restricts which IP addresses may connect. Leave empty to disable.", + "outbound_proxy_placeholder": "socks5h://127.0.0.1:1080", + "outbound_proxy_bypass": "Proxy bypass list", + "outbound_proxy_bypass_desc": "Requests to these addresses go out directly instead of through the proxy. Adding internal addresses avoids unnecessary detours.", + "outbound_proxy_bypass_placeholder": "Type an address and press Enter", + "outbound_proxy_test": "Test connection", "pagination": "List limits", "pagination_desc": "How far totals are counted on large lists, and the highest page number that can be requested directly. Beyond the cap the total is shown as \"N+\" and only the last-page jump is hidden — moving to the next page stays available.", "pagination_result_cap": "Total count cap", 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 318a030e..613ffb2d 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1786,6 +1786,13 @@ "dev_dashboard": "개발 대시보드", "sql_query_log": "SQL 쿼리 로그", "sql_query_log_desc": "실행된 SQL 쿼리를 로그에 기록합니다. 로그 파일 위치: /storage/logs/query.log", + "outbound_proxy": "아웃바운드 프록시 주소", + "outbound_proxy_desc": "사이트가 외부로 보내는 모든 요청(결제 승인, 코어 업데이트 확인, 알림 발송 등)이 이 서버를 거쳐 나갑니다. 접속 IP를 제한하는 외부 서비스를 연동할 때 사용합니다. 비워 두면 사용하지 않습니다.", + "outbound_proxy_placeholder": "socks5h://127.0.0.1:1080", + "outbound_proxy_bypass": "프록시 예외 목록", + "outbound_proxy_bypass_desc": "이 목록에 있는 주소로 보내는 요청은 프록시를 거치지 않고 바로 나갑니다. 내부망 주소를 넣어두면 불필요한 우회를 줄일 수 있습니다.", + "outbound_proxy_bypass_placeholder": "주소 입력 후 Enter", + "outbound_proxy_test": "연결 테스트", "pagination": "목록 한계값", "pagination_desc": "대용량 목록에서 총 건수를 세는 범위와 직접 요청 가능한 페이지 번호의 상한입니다. 상한을 넘으면 총 건수를 \"N건 이상\" 으로 표시하고 마지막 페이지 점프만 감춥니다 — 다음 페이지 이동은 그대로 열려 있습니다.", "pagination_result_cap": "총 건수 집계 상한", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json index d2cbd402..b27f4596 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json @@ -905,6 +905,219 @@ } } ] + }, + { + "id": "outbound_proxy_settings", + "type": "basic", + "name": "Div", + "if": "{{_local.form?.advanced?.debug_mode}}", + "props": { + "className": "ml-4 pl-4 border-l-2 border-blue-200 dark:border-blue-800 space-y-4" + }, + "children": [ + { + "id": "input_outbound_proxy", + "type": "basic", + "name": "Div", + "props": { + "className": "row-stack" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "$t:admin.settings.advanced.outbound_proxy" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:admin.settings.advanced.outbound_proxy_desc" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "advanced.outbound_proxy", + "placeholder": "$t:admin.settings.advanced.outbound_proxy_placeholder", + "autoComplete": "off", + "disabled": "{{_computed.isReadOnly}}", + "className": "{{_local.errors?.['advanced.outbound_proxy'] ? 'input-error' : ''}}" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['advanced.outbound_proxy']}}", + "props": { + "className": "form-error" + }, + "text": "{{_local.errors?.['advanced.outbound_proxy']?.[0] ?? ''}}" + }, + { + "id": "outbound_proxy_test_row", + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-3 mt-2" + }, + "children": [ + { + "id": "btn_test_outbound_proxy", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 bg-gray-700 dark:bg-gray-600 text-white dark:text-white text-xs font-medium rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 transition-colors whitespace-nowrap disabled:opacity-50", + "disabled": "{{_computed.isReadOnly || !_local.form?.advanced?.outbound_proxy || _local.outboundProxyTesting}}" + }, + "text": "$t:admin.settings.advanced.outbound_proxy_test", + "actions": [ + { + "event": "click", + "handler": "setState", + "params": { + "target": "local", + "outboundProxyTesting": true + } + }, + { + "event": "click", + "handler": "apiCall", + "target": "/api/admin/settings/test-outbound-proxy", + "auth_required": true, + "params": { + "method": "POST", + "body": { + "outbound_proxy": "{{_local.form?.advanced?.outbound_proxy}}", + "outbound_proxy_bypass": "{{_local.form?.advanced?.outbound_proxy_bypass ?? []}}" + } + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "local", + "outboundProxyTesting": false, + "outboundProxyTest": "{{response.data}}", + "outboundProxyTestMessage": "{{response.message}}" + } + }, + { + "handler": "toast", + "params": { + "type": "{{response.data?.success ? 'success' : 'error'}}", + "message": "{{response.message}}" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "local", + "outboundProxyTesting": false, + "outboundProxyTest": null, + "outboundProxyTestMessage": "{{error.message}}" + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] + } + ] + }, + { + "id": "outbound_proxy_test_result", + "type": "basic", + "name": "Span", + "if": "{{!!_local.outboundProxyTestMessage && !_local.outboundProxyTesting}}", + "props": { + "className": "{{_local.outboundProxyTest?.success ? 'text-xs text-green-600 dark:text-green-400' : 'text-xs text-red-600 dark:text-red-400'}}" + }, + "text": "{{_local.outboundProxyTest?.egress_ip ? (_local.outboundProxyTestMessage + ' (' + _local.outboundProxyTest.egress_ip + ')') : _local.outboundProxyTestMessage}}" + } + ] + } + ] + }, + { + "id": "input_outbound_proxy_bypass", + "type": "basic", + "name": "Div", + "props": { + "className": "row-stack" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "$t:admin.settings.advanced.outbound_proxy_bypass" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:admin.settings.advanced.outbound_proxy_bypass_desc" + }, + { + "type": "composite", + "name": "TagInput", + "props": { + "name": "advanced.outbound_proxy_bypass", + "creatable": true, + "placeholder": "$t:admin.settings.advanced.outbound_proxy_bypass_placeholder", + "className": "w-full", + "defaultVariant": "blue", + "disabled": "{{_computed.isReadOnly}}" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['advanced.outbound_proxy_bypass']}}", + "props": { + "className": "form-error" + }, + "text": "{{_local.errors?.['advanced.outbound_proxy_bypass']?.[0] ?? ''}}" + } + ] + } + ] } ] } diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json index 04fc26af..c783e329 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.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-admin_basic", - "version": "1.0.5", + "version": "1.0.6", "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 745366a6..90f5d65b 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.5", + "version": "1.0.6", "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 70f7689b..9c1f06ec 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.5", + "version": "1.0.6", "license": "MIT", "description": { "ko": "그누보드7 기본 관리자 템플릿", diff --git a/tests/Feature/Settings/OutboundProxySettingTest.php b/tests/Feature/Settings/OutboundProxySettingTest.php new file mode 100644 index 00000000..7801ac0f --- /dev/null +++ b/tests/Feature/Settings/OutboundProxySettingTest.php @@ -0,0 +1,322 @@ +saveSettings([ + '_tab' => 'advanced', + 'advanced' => [ + 'outbound_proxy' => 'socks5h://127.0.0.1:1080', + 'outbound_proxy_bypass' => ['g7.dev', 'localhost'], + ], + ]); + + $this->assertTrue($saved, '고급 탭 저장이 실패했습니다.'); + + $debug = app(ConfigRepositoryInterface::class)->getCategory('debug'); + + $this->assertSame( + 'socks5h://127.0.0.1:1080', + $debug['outbound_proxy'] ?? null, + 'debug.outbound_proxy 가 저장되지 않았습니다 — 고급 탭 분류표에서 누락되면 값이 조용히 버려집니다.' + ); + $this->assertSame(['g7.dev', 'localhost'], $debug['outbound_proxy_bypass'] ?? null); + } + + /** + * 기존 설치본에도 새 키가 기본값으로 노출되는지 확인합니다. + * + * 설정 JSON 은 defaults 와 병합되어 읽히므로 별도 마이그레이션 없이 키가 생겨야 합니다. + * + * @scenario debug_mode=on, proxy_value=empty, bypass_list=empty + * + * @effects proxy_setting_persists_to_debug_category + */ + public function test_new_keys_are_present_through_defaults_merge(): void + { + $debug = app(ConfigRepositoryInterface::class)->getCategory('debug'); + + $this->assertArrayHasKey('outbound_proxy', $debug); + $this->assertArrayHasKey('outbound_proxy_bypass', $debug); + $this->assertSame('', $debug['outbound_proxy']); + $this->assertSame([], $debug['outbound_proxy_bypass']); + } + + /** + * 적용할 수 없는 형태의 주소는 저장 검증에서 막습니다. + * + * @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty + * + * @effects proxy_setting_rejects_invalid_url + */ + public function test_invalid_proxy_url_fails_validation(): void + { + $validator = $this->validatorForAdvanced([ + 'outbound_proxy' => 'ftp://proxy.internal:21', + ]); + + $this->assertTrue( + $validator->fails(), + '허용 목록에 없는 스킴이 검증을 통과했습니다 — 저장은 되고 적용은 안 되는 상태가 됩니다.' + ); + $this->assertArrayHasKey('advanced.outbound_proxy', $validator->errors()->messages()); + } + + /** + * 유효한 주소와 빈 값은 검증을 통과합니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty + * + * @effects proxy_setting_rejects_invalid_url + */ + public function test_valid_and_empty_proxy_url_pass_validation(): void + { + foreach (['socks5h://127.0.0.1:1080', 'http://proxy.internal:3128', ''] as $url) { + $validator = $this->validatorForAdvanced(['outbound_proxy' => $url]); + + $this->assertFalse( + $validator->errors()->has('advanced.outbound_proxy'), + "유효한 주소 '{$url}' 가 거부됐습니다." + ); + } + } + + /** + * 예외 목록의 각 항목이 문자열·길이 검증을 받는지 확인합니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable + * + * @effects proxy_setting_rejects_invalid_url + */ + public function test_bypass_list_items_are_validated(): void + { + $validator = $this->validatorForAdvanced([ + 'outbound_proxy_bypass' => [str_repeat('a', 256)], + ]); + + $this->assertTrue($validator->fails()); + $this->assertArrayHasKey('advanced.outbound_proxy_bypass.0', $validator->errors()->messages()); + } + + /** + * 판정 결과가 있으면 Http 전역 옵션에 프록시가 실립니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable + * + * @effects proxy_applied_to_global_http_options + */ + public function test_resolved_proxy_is_applied_to_global_http_options(): void + { + $proxy = [ + 'http' => 'socks5h://127.0.0.1:1080', + 'https' => 'socks5h://127.0.0.1:1080', + 'no' => ['g7.dev'], + ]; + + config(['g7.outbound_proxy' => $proxy]); + $this->invokeOutboundProxyConfigurer(); + + $this->assertSame( + ['proxy' => $proxy], + $this->globalHttpOptions(), + 'Http 전역 옵션에 프록시가 실리지 않았습니다 — 설정은 저장되어도 실제 요청은 그대로 나갑니다.' + ); + } + + /** + * 판정 결과가 없으면 Http 전역 옵션을 건드리지 않습니다. + * + * 디버그 모드가 꺼져 있으면 판정이 null 을 돌려주므로, 이 경로가 프록시 미적용을 보장합니다. + * + * @scenario debug_mode=off, proxy_value=valid, bypass_list=empty + * + * @effects proxy_absent_leaves_global_http_options_untouched + */ + public function test_absent_proxy_leaves_global_http_options_untouched(): void + { + foreach ([null, [], ''] as $value) { + config(['g7.outbound_proxy' => $value]); + $this->invokeOutboundProxyConfigurer(); + + $this->assertSame( + [], + $this->globalHttpOptions(), + '프록시 미적용 상태인데 Http 전역 옵션이 설정됐습니다.' + ); + } + } + + /** + * 고급 탭 검증기를 구성합니다. + * + * @param array $advanced advanced 탭 입력값 + */ + private function validatorForAdvanced(array $advanced): Validator + { + $request = new SaveSettingsRequest; + $request->merge(['_tab' => 'advanced', 'advanced' => $advanced]); + $request->setContainer(app()); + + return validator( + $request->all(), + $request->rules(), + $request->messages(), + $request->attributes() + ); + } + + /** + * AppServiceProvider 의 프록시 적용 로직만 실행합니다. + */ + private function invokeOutboundProxyConfigurer(): void + { + $this->resetGlobalHttpOptions(); + + $provider = new AppServiceProvider($this->app); + $method = new ReflectionMethod($provider, 'configureOutboundProxy'); + $method->setAccessible(true); + $method->invoke($provider); + } + + /** + * Http 팩토리의 전역 옵션을 비웁니다. + */ + private function resetGlobalHttpOptions(): void + { + Http::globalOptions([]); + } + + /** + * Http 팩토리에 설정된 전역 옵션을 읽습니다. + * + * @return array + */ + private function globalHttpOptions(): array + { + $factory = $this->app->make(Factory::class); + $property = new ReflectionProperty($factory, 'globalOptions'); + $property->setAccessible(true); + + return (array) value($property->getValue($factory)); + } + + /** + * 연결 테스트가 프록시를 거친 출발지 IP 를 보고합니다. + * + * 출발지 IP 는 운영자가 결제사에 등록해야 하는 값이라, 저장하기 전에 알 수 있어야 합니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty + * + * @effects proxy_connection_test_reports_egress_ip + */ + public function test_connection_test_reports_egress_ip(): void + { + Http::fake(['*' => Http::response('203.0.113.9', 200)]); + + $result = app(OutboundProxyTester::class)->test('socks5h://127.0.0.1:1080'); + + $this->assertTrue($result['success']); + $this->assertSame('203.0.113.9', $result['egress_ip']); + $this->assertSame('settings.outbound_proxy_test_success', $result['message_key']); + } + + /** + * 연결 테스트는 제출된 값을 검사합니다 — 저장된 설정이나 전역 옵션을 보지 않습니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable + * + * @effects proxy_connection_test_reports_egress_ip + */ + public function test_connection_test_uses_submitted_proxy_not_stored_settings(): void + { + config(['g7.outbound_proxy' => null]); + Http::globalOptions([]); + + Http::fake(['*' => Http::response('203.0.113.9', 200)]); + + $result = app(OutboundProxyTester::class)->test('socks5h://10.0.0.9:1080', ['g7.dev']); + + // 전역 프록시가 없는 상태에서도 제출값으로 요청이 나갔다. + Http::assertSentCount(1); + $this->assertTrue($result['success']); + + // 전역 옵션은 비어 있는 채여야 한다 — 테스트가 전역 상태를 바꾸면 이후 모든 요청이 + // 저장하지도 않은 프록시를 타게 된다. + $this->assertSame([], $this->globalHttpOptions()); + } + + /** + * 적용 불가 주소는 외부 호출 없이 즉시 거부합니다. + * + * @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty + * + * @effects proxy_connection_test_reports_egress_ip + */ + public function test_connection_test_rejects_invalid_url_without_calling_out(): void + { + Http::fake(); + + $result = app(OutboundProxyTester::class)->test('ftp://proxy.internal:21'); + + $this->assertFalse($result['success']); + $this->assertSame('settings.outbound_proxy_test_invalid_url', $result['message_key']); + Http::assertNothingSent(); + } + + /** + * 조회 대상이 없으면 확인 불가로 보고합니다 — 실패와 구분되어야 합니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty + * + * @effects proxy_connection_test_reports_egress_ip + */ + public function test_connection_test_reports_when_no_lookup_target_is_configured(): void + { + config(['core.outbound_proxy.egress_lookup_urls' => []]); + Http::fake(); + + $result = app(OutboundProxyTester::class)->test('socks5h://127.0.0.1:1080'); + + $this->assertFalse($result['success']); + $this->assertSame('settings.outbound_proxy_test_no_lookup_url', $result['message_key']); + Http::assertNothingSent(); + } +} diff --git a/tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts b/tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts new file mode 100644 index 00000000..2059f61f --- /dev/null +++ b/tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts @@ -0,0 +1,152 @@ +/** + * E2E: 환경설정 > 고급 — 아웃바운드 HTTP 프록시 (#600) + * + * 시나리오 매니페스트: tests/scenarios/outbound-http-proxy.yaml — 마킹은 각 테스트의 + * scenario(k=v 조합)·effects 주석이 담당하며, 헤더 요약 마킹은 파서 형식이 아니다 + * + * 배경: 접속 IP 를 제한하는 결제사 API 를 로컬에서 테스트하려면 서버가 내보내는 요청의 + * 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는 축이라 코어 설정으로 도입했다. + * + * 검증: + * 1. 디버그 모드가 꺼져 있으면 프록시 입력칸이 화면에 없다 + * 2. 디버그 모드를 켜면 프록시 주소 Input 과 예외 목록 TagInput 이 마운트된다 + * 3. 적용할 수 없는 형태의 주소는 저장이 거부되고 해당 필드에 inline 에러가 표시된다 + */ +import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; + +/** + * 관리자 환경설정 고급 탭 진입. + * + * 이 화면은 하드 로드 시 설정 응답이 도착하기 전에도 폼이 렌더되어 조작할 수 있고, + * 그 조작은 뒤늦게 도착한 `initLocal` 시드에 덮인다(실측 노출 창 300~430ms). + * 제품 차원의 잠금은 두지 않기로 했으므로(#600 PO 결정), 테스트가 시드 완료를 + * 기다린 뒤에 조작한다. + */ +async function gotoAdvancedTab(page: import('@playwright/test').Page): Promise { + await page.goto('/admin/settings?tab=advanced'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator('#card_debug_settings')).toBeAttached({ timeout: 20_000 }); + + await expect + .poll( + () => + page.evaluate( + () => Object.keys((window as any).G7Core?.state?.getLocal?.()?.form?.advanced ?? {}).length + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(0); +} + +/** 디버그 모드 토글을 켠다 (이미 켜져 있으면 그대로 둔다) */ +async function enableDebugMode(page: import('@playwright/test').Page): Promise { + const checkbox = page.locator('input[name="advanced.debug_mode"]').first(); + await expect(checkbox).toBeAttached({ timeout: 10_000 }); + + if (!(await checkbox.isChecked())) { + // Toggle 은 sr-only checkbox 를 감싼 wrapper 가 클릭 대상이다 + await page.locator('#toggle_debug_mode .toggle-switch-wrapper').first().click(); + } + + await expect(page.locator('#outbound_proxy_settings')).toBeAttached({ timeout: 10_000 }); +} + +// @scenario debug_mode=off, proxy_value=valid, bypass_list=empty +// @effects proxy_inputs_hidden_when_debug_mode_off +test('#600 - 디버그 모드가 꺼져 있으면 프록시 입력칸이 없다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoAdvancedTab(page); + expect(page.url()).not.toMatch(/\/admin\/login/); + + // 존재 확정: 같은 카드의 SQL 쿼리 로그 토글은 조건 없이 렌더된다. + // 이 확인이 없으면 아래 부재 단언이 "아직 렌더 전" 과 구분되지 않는다. + await expect(page.locator('[name="advanced.sql_query_log"]').first()).toBeAttached(); + + await expect(page.locator('#outbound_proxy_settings')).toHaveCount(0); + await expect(page.locator('input[name="advanced.outbound_proxy"]')).toHaveCount(0); +}); + +// @scenario debug_mode=on, proxy_value=valid, bypass_list=empty +// @effects proxy_inputs_visible_when_debug_mode_on +test('@smoke #600 - 디버그 모드를 켜면 프록시 주소와 예외 목록이 마운트된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoAdvancedTab(page); + await enableDebugMode(page); + + const proxyInput = page.locator('input[name="advanced.outbound_proxy"]').first(); + await expect(proxyInput).toBeAttached(); + + await proxyInput.fill('socks5h://127.0.0.1:1080'); + await expect(proxyInput).toHaveValue('socks5h://127.0.0.1:1080'); + + // 예외 목록은 TagInput — 자유 입력 후 Enter 로 항목이 된다 + await expect(page.locator('#input_outbound_proxy_bypass')).toBeAttached(); +}); + +// @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty +// @effects proxy_setting_rejects_invalid_url +test('#600 - 허용되지 않는 형태의 프록시 주소는 저장이 거부된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoAdvancedTab(page); + await enableDebugMode(page); + + const proxyInput = page.locator('input[name="advanced.outbound_proxy"]').first(); + await proxyInput.fill('ftp://proxy.internal:21'); + + const saveResponse = page.waitForResponse( + (response) => response.request().method() === 'POST' && /\/api\/admin\/settings\/?$/.test(new URL(response.url()).pathname), + { timeout: 20_000 } + ); + + await page.getByRole('button', { name: /저장|Save/ }).first().click(); + + const response = await saveResponse; + expect(response.status()).toBe(422); + + await expect(page.locator('#input_outbound_proxy .form-error')).toBeVisible({ timeout: 10_000 }); +}); + +// @scenario debug_mode=on, proxy_value=valid, bypass_list=empty +// @effects proxy_connection_test_button_wired +test('#600 - 연결 테스트가 프록시를 거친 출발지 IP 를 화면에 보고한다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoAdvancedTab(page); + await enableDebugMode(page); + + // 프록시 주소를 비운 상태에서는 테스트 버튼이 잠겨 있다 (보낼 값이 없다) + await expect(page.locator('#btn_test_outbound_proxy')).toBeDisabled(); + + await page.locator('input[name="advanced.outbound_proxy"]').first().fill('socks5h://127.0.0.1:1080'); + await expect(page.locator('#btn_test_outbound_proxy')).toBeEnabled(); + + // 외부 프록시 서버는 E2E 환경에 없으므로 응답만 스텁한다 — + // 버튼 배선·요청 페이로드·결과 렌더는 전부 실경로다. + await page.route('**/api/admin/settings/test-outbound-proxy', async (route) => { + const body = route.request().postDataJSON(); + expect(body.outbound_proxy).toBe('socks5h://127.0.0.1:1080'); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + message: '프록시 연결에 성공했습니다. 외부 서비스에는 이 IP 로 보입니다.', + data: { success: true, egress_ip: '203.0.113.9', elapsed_ms: 120, error: null }, + }), + }); + }); + + await page.locator('#btn_test_outbound_proxy').click(); + + const result = page.locator('#outbound_proxy_test_result'); + await expect(result).toBeVisible({ timeout: 10_000 }); + await expect(result).toContainText('203.0.113.9'); +}); diff --git a/tests/Unit/Support/OutboundProxyTest.php b/tests/Unit/Support/OutboundProxyTest.php new file mode 100644 index 00000000..4a8cc9ed --- /dev/null +++ b/tests/Unit/Support/OutboundProxyTest.php @@ -0,0 +1,258 @@ + false, + 'outbound_proxy' => 'socks5h://127.0.0.1:1080', + 'outbound_proxy_bypass' => ['internal.example'], + ]); + + $this->assertNull( + $resolved, + '디버그 모드가 꺼진 상태에서 프록시가 적용되었습니다 — 화면 조건부 렌더링은 저장 API 직접 호출을 막지 못하므로 이 판정이 유일한 게이트입니다.' + ); + } + + /** + * mode 키 자체가 없는 설정도 미적용으로 판정합니다. + * + * @scenario debug_mode=off, proxy_value=valid, bypass_list=empty + * + * @effects proxy_not_applied_when_debug_mode_off + */ + public function test_proxy_is_not_applied_when_mode_key_is_absent(): void + { + $this->assertNull( + OutboundProxy::resolve(['outbound_proxy' => 'http://proxy.internal:3128']), + 'mode 키 부재를 디버그 모드 ON 으로 해석했습니다 — 판정은 fail-closed 여야 합니다.' + ); + } + + /** + * 디버그 모드가 켜져 있고 주소가 유효하면 http/https 양쪽에 적용합니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty + * + * @effects proxy_applied_when_debug_mode_on + */ + public function test_proxy_is_applied_for_both_schemes_when_debug_mode_is_on(): void + { + $resolved = OutboundProxy::resolve([ + 'mode' => true, + 'outbound_proxy' => 'socks5h://127.0.0.1:1080', + ]); + + $this->assertSame([ + 'http' => 'socks5h://127.0.0.1:1080', + 'https' => 'socks5h://127.0.0.1:1080', + 'no' => [], + ], $resolved); + } + + /** + * 값이 비어 있으면 프록시를 쓰지 않는 상태로 판정합니다. + * + * @scenario debug_mode=on, proxy_value=empty, bypass_list=empty + * + * @effects proxy_applied_when_debug_mode_on + */ + public function test_empty_proxy_value_disables_proxy(): void + { + $this->assertNull(OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => ''])); + $this->assertNull(OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => ' '])); + $this->assertNull(OutboundProxy::resolve(['mode' => true])); + } + + /** + * 허용 목록에 없는 스킴과 호스트 없는 주소는 적용하지 않습니다. + * + * @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty + * + * @effects proxy_rejects_disallowed_scheme + * + * @dataProvider invalidProxyUrls + */ + public function test_invalid_proxy_url_is_not_applied(string $url, string $why): void + { + $this->assertNull( + OutboundProxy::resolve(['mode' => true, 'outbound_proxy' => $url]), + $why + ); + $this->assertFalse(OutboundProxy::isValidUrl($url), $why); + } + + /** + * 적용 불가 주소 목록. + * + * @return array + */ + public static function invalidProxyUrls(): array + { + return [ + '스킴 없음' => ['127.0.0.1:1080', '스킴 없는 주소가 통과했습니다 — cURL 이 프록시로 해석하지 못합니다.'], + '파일 스킴' => ['file:///etc/passwd', 'file 스킴이 통과했습니다.'], + 'ftp 스킴' => ['ftp://proxy.internal:21', '허용 목록에 없는 스킴이 통과했습니다.'], + '호스트 없음' => ['http://', '호스트 없는 주소가 통과했습니다.'], + '빈 문자열' => ['', '빈 값이 유효 주소로 판정됐습니다.'], + ]; + } + + /** + * 허용 스킴은 모두 적용 가능해야 합니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=empty + * + * @effects proxy_applied_when_debug_mode_on + */ + public function test_every_allowed_scheme_is_accepted(): void + { + foreach (OutboundProxy::ALLOWED_SCHEMES as $scheme) { + $this->assertTrue( + OutboundProxy::isValidUrl($scheme.'://proxy.internal:1080'), + "허용 목록의 스킴 {$scheme} 이 거부됐습니다 — 목록과 판정이 어긋나면 저장은 되는데 적용되지 않습니다." + ); + } + } + + /** + * 예외 목록은 공백 제거·빈 항목 제거·중복 제거 후 순번을 다시 매깁니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable + * + * @effects proxy_bypass_list_normalized + */ + public function test_bypass_list_is_normalized(): void + { + $resolved = OutboundProxy::resolve([ + 'mode' => true, + 'outbound_proxy' => 'http://proxy.internal:3128', + 'outbound_proxy_bypass' => [' g7.dev ', '', 'g7.dev', 'localhost', 42, null], + ]); + + $this->assertSame(['g7.dev', 'localhost'], $resolved['no']); + $this->assertSame( + [0, 1], + array_keys($resolved['no']), + '예외 목록 키가 비연속입니다 — JSON 직렬화 시 객체가 되어 목록으로 읽히지 않습니다.' + ); + } + + /** + * 예외 목록이 배열이 아니면 빈 목록으로 취급합니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable + * + * @effects proxy_bypass_list_normalized + */ + public function test_non_array_bypass_list_becomes_empty(): void + { + $resolved = OutboundProxy::resolve([ + 'mode' => true, + 'outbound_proxy' => 'http://proxy.internal:3128', + 'outbound_proxy_bypass' => 'g7.dev', + ]); + + $this->assertSame([], $resolved['no']); + } + + /** + * 적용 중인 프록시는 curl 옵션 형태로도 제공됩니다. + * + * `Http::` 파사드를 쓰지 못하는 호출 지점(외부 SDK 규약상 curl 핸들을 직접 다뤄야 하는 + * 경우)이 같은 프록시를 타려면 이 통로가 필요합니다. 이 통로가 없으면 그 경로만 조용히 + * 직접 나가고, 요청은 정상 성공하므로 아무 신호도 남지 않습니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable + * + * @effects proxy_exposed_as_curl_options + */ + public function test_resolved_proxy_is_exposed_as_curl_options(): void + { + config(['g7.outbound_proxy' => OutboundProxy::resolve([ + 'mode' => true, + 'outbound_proxy' => 'socks5h://127.0.0.1:1080', + 'outbound_proxy_bypass' => ['g7.dev', 'localhost'], + ])]); + + $this->assertSame([ + CURLOPT_PROXY => 'socks5h://127.0.0.1:1080', + CURLOPT_NOPROXY => 'g7.dev,localhost', + ], OutboundProxy::curlOptions()); + } + + /** + * 프록시 미적용 상태에서는 빈 배열이라 curl_setopt_array 에 그대로 넘겨도 무해합니다. + * + * @scenario debug_mode=off, proxy_value=valid, bypass_list=empty + * + * @effects proxy_exposed_as_curl_options + */ + public function test_curl_options_are_empty_when_proxy_is_not_applied(): void + { + config(['g7.outbound_proxy' => OutboundProxy::resolve([ + 'mode' => false, + 'outbound_proxy' => 'socks5h://127.0.0.1:1080', + ])]); + + $this->assertSame([], OutboundProxy::curlOptions()); + } + + /** + * 저장 전 연결 테스트와 실제 적용이 같은 조립·정규화를 거칩니다. + * + * 이 테스트의 목적은 "저장 전에 확인한다" 는 기능의 전제를 지키는 것입니다. 조립을 두 곳에서 + * 각각 하면 예외 목록의 공백·빈 항목·중복 처리가 어긋나, 운영자가 확인한 구성과 저장 후 + * 실제로 적용되는 구성이 달라집니다. 두 구성 모두 정상 동작하므로 어긋남 자체는 아무런 + * 오류도 남기지 않습니다. + * + * @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable + * + * @effects proxy_bypass_list_normalized + */ + public function test_connection_test_and_applied_config_share_the_same_assembly(): void + { + $url = ' socks5h://127.0.0.1:1080 '; + $bypass = [' g7.dev ', '', 'g7.dev', 'localhost']; + + // 실제 적용 경로 (SettingsServiceProvider → config('g7.outbound_proxy')) + $applied = OutboundProxy::resolve([ + 'mode' => true, + 'outbound_proxy' => $url, + 'outbound_proxy_bypass' => $bypass, + ]); + + // 저장 전 연결 테스트 경로 (OutboundProxyTester) + $tested = OutboundProxy::options($url, $bypass); + + $this->assertSame($applied, $tested); + $this->assertSame(['g7.dev', 'localhost'], $tested['no']); + $this->assertSame('socks5h://127.0.0.1:1080', $tested['https']); + } +} diff --git a/tests/scenarios/outbound-http-proxy.yaml b/tests/scenarios/outbound-http-proxy.yaml new file mode 100644 index 00000000..f1a10153 --- /dev/null +++ b/tests/scenarios/outbound-http-proxy.yaml @@ -0,0 +1,51 @@ +feature: 코어 아웃바운드 HTTP 프록시 설정 + +description: | + 코어가 바깥으로 내보내는 모든 HTTP 요청(결제 승인, 코어 업데이트 조회, GeoIP 내려받기, + 알림 웹훅)을 운영자가 지정한 프록시로 경유시키는 환경설정. 접속 IP 를 제한하는 외부 + 서비스를 로컬·스테이징 환경에서 연동하기 위한 것이다. + + 핵심 동작: + - 저장 키는 debug 카테고리 소속 (debug.outbound_proxy, debug.outbound_proxy_bypass) + - 적용 게이트는 디버그 모드 — App\Support\OutboundProxy 가 단독 판정 + - 디버그 모드 OFF 면 저장값이 남아 있어도 미적용 (화면 조건부 렌더링은 게이트가 아님) + - 적용은 Http::globalOptions 전역 옵션 — 확장의 Http:: 호출까지 함께 경유 + - 개별 요청의 withOptions(['proxy' => ...]) 가 전역 옵션보다 우선 + - Http:: 파사드를 쓰지 못하는 curl 직접 호출은 OutboundProxy::curlOptions() 로 편입 + - 저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고 출발지 IP 를 보고 + + 축 설계: 판정에 실제로 영향을 주는 입력만 축으로 둔다. 검증 지점(단위/저장/적용/화면)은 + 같은 조합을 여러 계층에서 확인하는 것이므로 축이 아니라 테스트 파일의 분담이다. + +axes: + debug_mode: [on, off] + proxy_value: [valid, invalid, empty] + bypass_list: [empty, normalizable] + +exclusions: + - { debug_mode: off, proxy_value: invalid, reason: "게이트가 먼저 걸려 주소 형태를 평가하지 않는다" } + - { debug_mode: off, proxy_value: empty, reason: "동일 — 미적용 판정이 주소보다 앞선다" } + - { debug_mode: off, bypass_list: normalizable, reason: "미적용 판정에서는 예외 목록을 계산하지 않는다" } + - { proxy_value: invalid, bypass_list: normalizable, reason: "주소가 거부되면 예외 목록은 계산되지 않는다" } + - { proxy_value: empty, bypass_list: normalizable, reason: "프록시를 쓰지 않으면 예외 목록은 의미가 없다" } + +effects: + - proxy_not_applied_when_debug_mode_off + - proxy_applied_when_debug_mode_on + - proxy_rejects_disallowed_scheme + - proxy_bypass_list_normalized + - proxy_setting_persists_to_debug_category + - proxy_setting_rejects_invalid_url + - proxy_applied_to_global_http_options + - proxy_absent_leaves_global_http_options_untouched + - proxy_inputs_hidden_when_debug_mode_off + - proxy_inputs_visible_when_debug_mode_on + - proxy_exposed_as_curl_options + - proxy_connection_test_reports_egress_ip + - proxy_connection_test_button_wired + +test_files: + - tests/Unit/Support/OutboundProxyTest.php + - tests/Feature/Settings/OutboundProxySettingTest.php + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-outbound-proxy-visibility.test.tsx + - tests/Playwright/specs/admin/settings-outbound-proxy.spec.ts