feat(settings): 코어 아웃바운드 HTTP 프록시 설정 추가

접속 IP 를 제한하는 결제사 API 를 로컬·스테이징에서 연동하려면 서버가
내보내는 요청의 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는
축이라 코어 환경설정으로 도입한다.

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

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

저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고, 그 프록시를
거쳤을 때 외부에 보이는 IP 를 함께 보고한다. 운영자가 결제사에 등록할
값이라 저장하고 나서 되짚지 않도록 했다. 적용과 같은 조립을 거치므로
확인한 구성과 저장 후 적용되는 구성이 어긋나지 않는다.
This commit is contained in:
HeuJung
2026-08-20 17:01:27 +09:00
parent 0e8b625436
commit a7b7c64573
55 changed files with 2040 additions and 30 deletions
@@ -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
@@ -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<any> = ({ className, children }) => <div className={className}>{children}</div>;
const TestInput: React.FC<any> = ({ name, type }) => <input name={name} type={type} data-testid={name} />;
const TestToggle: React.FC<any> = ({ name }) => (
<input type="checkbox" role="switch" name={name} data-testid={`toggle-${name}`} />
);
const TestTagInput: React.FC<any> = ({ name }) => <div data-testid={`tags-${name}`} />;
const TestButton: React.FC<any> = ({ children, text, disabled }) => (
<button type="button" disabled={disabled} data-testid="btn-test-proxy">{children || text}</button>
);
const TestA: React.FC<any> = ({ children, text }) => <a href="#">{children || text}</a>;
const TestSpan: React.FC<any> = ({ children, text }) => <span>{children || text}</span>;
const TestP: React.FC<any> = ({ children, text }) => <p>{children || text}</p>;
const TestH3: React.FC<any> = ({ children, text }) => <h3>{children || text}</h3>;
const TestFragment: React.FC<any> = ({ 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<string, unknown>) {
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);
}
});
});
@@ -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",
@@ -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",
@@ -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": "총 건수 집계 상한",
@@ -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] ?? ''}}"
}
]
}
]
}
]
}
+2 -2
View File
@@ -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",
@@ -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",
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.5",
"version": "1.0.6",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",