Files
Gnuboard7/app/Services/OutboundProxyTester.php
T
HeuJung a7b7c64573 feat(settings): 코어 아웃바운드 HTTP 프록시 설정 추가
접속 IP 를 제한하는 결제사 API 를 로컬·스테이징에서 연동하려면 서버가
내보내는 요청의 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는
축이라 코어 환경설정으로 도입한다.

게이트는 디버그 모드이며 판정은 OutboundProxy 한 곳이 소유한다. 화면의
조건부 렌더링은 편의일 뿐이라 저장 API 직접 호출을 막지 못하므로, 실질
게이트를 판정 지점에 둔다. 주입·적용 지점은 결과만 소비한다.

적용은 Http::globalOptions 전역 옵션이라 확장의 Http:: 호출까지 함께
경유한다. 외부 연동 규약상 curl 핸들을 직접 다뤄야 하는 확장은
OutboundProxy::curlOptions 로 같은 프록시를 탄다 — KG이니시스 본인인증
승인 요청과 CBT 연결 점검을 이 통로로 편입했다. CBT 의 TCP 443 확인은
원시 소켓으로는 프록시를 태울 수 없어 curl CONNECT_ONLY 로 교체했다.

저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고, 그 프록시를
거쳤을 때 외부에 보이는 IP 를 함께 보고한다. 운영자가 결제사에 등록할
값이라 저장하고 나서 되짚지 않도록 했다. 적용과 같은 조립을 거치므로
확인한 구성과 저장 후 적용되는 구성이 어긋나지 않는다.
2026-08-20 17:01:27 +09:00

117 lines
4.5 KiB
PHP

<?php
namespace App\Services;
use App\Support\OutboundProxy;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 아웃바운드 프록시 연결 테스트.
*
* 운영자가 입력한 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 나갔을 때 상대편에
* 어떤 IP 로 보이는지 확인합니다. 출발지 IP 는 결제사·외부 서비스에 등록해야 하는 값이라
* 테스트의 핵심 산출물입니다 — 프록시를 켜기 전에 어떤 IP 를 등록해야 하는지 알 수 있어야
* 저장하고 나서 되짚는 일이 없습니다.
*
* 검사 대상은 **저장된 설정이 아니라 이번에 제출된 값**입니다. 그래서 전역 옵션에 의존하지
* 않고 요청마다 프록시를 명시합니다 — 저장 전에 확인하는 것이 목적이기 때문입니다.
*
* @since 7.1.0
*/
class OutboundProxyTester
{
/**
* 제출된 프록시 설정으로 외부 연결을 시도합니다.
*
* @param string $proxyUrl 검사할 프록시 주소
* @param array<int, string> $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,
];
}
}