Skip to content

Commit 71e39d0

Browse files
SOIVclaude
andcommitted
feat(admin): Cloudflare Tunnel 관리 기능 추가
- Quick / Named Tunnel 선택 가능한 관리자 패널 UI 추가 - TunnelManager 싱글턴으로 cloudflared 프로세스 생명주기 관리 - cloudflared stdout/stderr를 서버 로그([cloudflared] 태그)로 중계 - 개발 모드 시 Vite(5173), 프로덕션 시 API(3000)로 자동 연결 - WSL2 IPv6 해석 오류(Error 1033) 방지를 위해 127.0.0.1 사용 - Vite allowedHosts: true 설정으로 trycloudflare.com 도메인 허용 - 복사 버튼 HTTP/IP 환경 fallback(execCommand) 처리 - tunnel.config.json .gitignore 추가 (토큰 포함 가능) - TODO.md 기술 부채(ESLint 9, otplib) 및 배포 문서 업데이트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 76c357f commit 71e39d0

10 files changed

Lines changed: 642 additions & 159 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,4 @@ local/
138138
# =============================================================================
139139
fieldstack.config.json
140140
installed.lock
141+
tunnel.config.json

TODO.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,42 @@
2828
- 전체, 일반, 프로모션(무료)를 구분하여 표기
2929
- 비활성 구독을 분리하여 표시
3030
- [ ] 그 외 수정할 부분 추가로 확인하고 보충 및 수정, 로드맵 진행 할 것
31-
- [ ]**구독 추가 및 수정 관련**
31+
- [x]**구독 추가 및 수정 관련**
3232
- [x] 비고/메모란을 삭제
3333
- [ ] 구독 시작일에서 날짜 뒤에 ()가 표시되는 부분을 수정
34+
- Chromium 엔진의 자체 이슈라서 해결이 불가능 ([Issue #263320](https://issues.chromium.org/issues/40326106))
3435
- [ ]**구독 상세 패널 관련**
3536
- [x] 누적 통계에서 환율이 적용되지 않아 8달러가 8원이 되는 문제
3637
- [x] 메모를 "비고/메모"로 수정
3738
- [x] 가격 변경뿐만 아니라 다른 히스토리도 추가할 수 있도록 수정
3839
- 구독 해지 및 재개도 직접 히스토리로 추가할 수 있도록
3940
- [x] 사용 일수의 표기를 "-년 -개월(-일)" 으로 수정
4041
- 예시로 Youtube Premium이 2019-02-12부터 구독했으면 "7년 2개월(2,626일)"으로 표시되도록
41-
- [ ] 현재 금액, 전체 누적과 같은 텍스트가 다크 모드에서 잘 안보이는 색상으로 되어 있어 이 부분을 수정
42+
- [x] 현재 금액, 전체 누적과 같은 텍스트가 다크 모드에서 잘 안보이는 색상으로 되어 있어 이 부분을 수정
4243
- 되도록이면 그냥 흰색 텍스트가 좋을 듯.
4344
- 그룹 이름은 제외(충분히 잘 보임)
45+
- [ ] 히스토리 상태에 맞추어 누적 및 사용 기간 계산 되도록
4446
- [ ] 버그 및 오류 잡기
4547
- 이전에 사용량 이슈로 해결하지 못한 버그 및 오류가 존제할 수 있음
48+
- 관리자 설정 페이지 수정
49+
- Cloudflare Tunnel탭에서 다음을 수정
50+
- [ ] 설치 상태 추가 및 실행 중 상태 표시의 위치를 조정
51+
- [ ] Cloudflare Tunnel의 로그를 띄울 수 있도록 수정
52+
- [ ] Cloudflare Tunnel 탭의 이름을 터널에서 '리버스 프록시'으로 수정ㅇ
4653

4754

55+
# 기술 부채 / 의존성 정리
56+
57+
- [ ] ESLint 8 → 9 업그레이드
58+
- 루트 `package.json``eslint@^8.57.1`이 deprecated
59+
- ESLint 9는 설정 포맷이 flat config로 변경되어 `.eslintrc` 파일도 함께 수정 필요
60+
- 해결되는 WARN: `eslint@8.57.1`, `@humanwhocodes/config-array`, `@humanwhocodes/object-schema`
61+
- [ ] `otplib` 교체 검토
62+
- `packages/core`에서 사용 중인 `otplib@^12.0.1`이 사실상 유지보수 중단 상태
63+
- `@noble/otpauth` 등 활발히 유지되는 라이브러리로 교체 검토
64+
- 해결되는 WARN: `@otplib/plugin-crypto`, `@otplib/plugin-thirty-two`, `@otplib/preset-default`, 일부 `glob` / `inflight` / `rimraf`
65+
- `prebuild-install` (better-sqlite3 내부)은 간접 의존성이라 직접 해결 불가
66+
4867
# idea
4968

5069
- 봐서 홈에다가 환율을 표시하는 것도 좋을 듯.

apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"dotenv": "^17.3.1",
2525
"express": "^5.1.0",
2626
"node-cron": "^3.0.3",
27-
"zod": "^3.25.28"
27+
"zod": "^3.25.28",
28+
"cloudflared": "^0.7.1"
2829
}
2930
}

apps/api/src/routes/admin.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from 'zod';
33

44
import { requireAuth } from '../middleware/require-auth';
55
import { clearConfig, clearInstalled, scheduleRestart } from '../setup/mode';
6+
import { tunnelManager } from '../tunnel/cloudflare-tunnel';
67
import type { AppServices } from '../app';
78

89
// ── 입력 스키마 ───────────────────────────────────────────────
@@ -177,5 +178,53 @@ export function createAdminRouter(services: AppServices): Router {
177178
}
178179
});
179180

181+
// ── Cloudflare Tunnel ─────────────────────────────────────────
182+
183+
/** GET /admin/tunnel/status */
184+
router.get('/tunnel/status', requireAuth(services.jwtManager), (_req, res) => {
185+
res.json({ success: true, data: tunnelManager.status });
186+
});
187+
188+
/** GET /admin/tunnel/config */
189+
router.get('/tunnel/config', requireAuth(services.jwtManager), (_req, res) => {
190+
res.json({ success: true, data: tunnelManager.getConfig() });
191+
});
192+
193+
/** PUT /admin/tunnel/config */
194+
const TunnelConfigBody = z.object({
195+
mode: z.enum(['quick', 'named']),
196+
token: z.string(),
197+
});
198+
199+
router.put('/tunnel/config', requireAuth(services.jwtManager), (req, res) => {
200+
const parsed = TunnelConfigBody.safeParse(req.body);
201+
if (!parsed.success) {
202+
res.status(400).json({ success: false, error: parsed.error.flatten() });
203+
return;
204+
}
205+
tunnelManager.setConfig(parsed.data);
206+
res.json({ success: true });
207+
});
208+
209+
/** POST /admin/tunnel/start */
210+
router.post('/tunnel/start', requireAuth(services.jwtManager), async (_req, res) => {
211+
try {
212+
const { url } = await tunnelManager.start();
213+
res.json({ success: true, data: { url } });
214+
} catch (err) {
215+
res.status(500).json({ success: false, error: (err as Error).message });
216+
}
217+
});
218+
219+
/** POST /admin/tunnel/stop */
220+
router.post('/tunnel/stop', requireAuth(services.jwtManager), (_req, res) => {
221+
try {
222+
tunnelManager.stop();
223+
res.json({ success: true });
224+
} catch (err) {
225+
res.status(400).json({ success: false, error: (err as Error).message });
226+
}
227+
});
228+
180229
return router;
181230
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
import { log } from '../middleware/logger';
5+
6+
// ── 타입 ──────────────────────────────────────────────────────────
7+
8+
export type TunnelMode = 'quick' | 'named';
9+
10+
export interface TunnelConfig {
11+
mode: TunnelMode;
12+
token: string;
13+
}
14+
15+
export interface TunnelStatus {
16+
running: boolean;
17+
url: string | null;
18+
mode: TunnelMode | null;
19+
}
20+
21+
// ── 설정 파일 경로 ────────────────────────────────────────────────
22+
23+
const CONFIG_PATH = path.resolve(process.cwd(), 'tunnel.config.json');
24+
25+
function loadConfig(): TunnelConfig {
26+
try {
27+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
28+
return JSON.parse(raw) as TunnelConfig;
29+
} catch {
30+
return { mode: 'quick', token: '' };
31+
}
32+
}
33+
34+
function saveConfig(cfg: TunnelConfig): void {
35+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
36+
}
37+
38+
// ── 터널 매니저 (싱글턴) ──────────────────────────────────────────
39+
40+
class CloudflareTunnelManager {
41+
private _running = false;
42+
private _url: string | null = null;
43+
private _mode: TunnelMode | null = null;
44+
private _tunnelInstance: { stop: () => boolean } | null = null;
45+
46+
get status(): TunnelStatus {
47+
return { running: this._running, url: this._url, mode: this._mode };
48+
}
49+
50+
getConfig(): TunnelConfig {
51+
return loadConfig();
52+
}
53+
54+
setConfig(cfg: TunnelConfig): void {
55+
saveConfig(cfg);
56+
}
57+
58+
async start(): Promise<{ url: string }> {
59+
if (this._running) {
60+
if (this._url) return { url: this._url };
61+
throw new Error('터널이 이미 시작 중입니다.');
62+
}
63+
64+
const cfg = loadConfig();
65+
66+
// cloudflared 패키지 동적 import
67+
let TunnelClass: typeof import('cloudflared').Tunnel;
68+
try {
69+
const mod = await import('cloudflared');
70+
TunnelClass = mod.Tunnel;
71+
} catch {
72+
throw new Error('cloudflared 패키지가 설치되어 있지 않습니다. pnpm install을 실행해주세요.');
73+
}
74+
75+
this._running = true;
76+
this._mode = cfg.mode;
77+
78+
try {
79+
// 개발 모드에서는 Vite dev server(5173)로, 프로덕션에서는 API 서버로 연결
80+
// localhost 대신 127.0.0.1 사용 — WSL2에서 localhost가 ::1(IPv6)로 해석되어
81+
// Vite가 응답하지 못하는 Error 1033 방지
82+
const isDev = process.env['NODE_ENV'] !== 'production';
83+
const localUrl = isDev
84+
? `http://127.0.0.1:${process.env['VITE_PORT'] ?? 5173}`
85+
: `http://127.0.0.1:${process.env['PORT'] ?? 3000}`;
86+
87+
log.info('tunnel', `starting ${cfg.mode} tunnel → ${localUrl}`);
88+
89+
const tunnelInstance =
90+
cfg.mode === 'named'
91+
? (() => {
92+
if (!cfg.token) throw new Error('Named Tunnel 토큰이 설정되어 있지 않습니다.');
93+
return TunnelClass.withToken(cfg.token);
94+
})()
95+
: TunnelClass.quick(localUrl);
96+
97+
this._tunnelInstance = tunnelInstance;
98+
99+
// cloudflared stdout/stderr를 서버 로그로 중계
100+
tunnelInstance.on('stdout', (data: string) => {
101+
for (const line of data.trim().split('\n')) {
102+
if (line.trim()) log.info('cloudflared', line.trim());
103+
}
104+
});
105+
tunnelInstance.on('stderr', (data: string) => {
106+
for (const line of data.trim().split('\n')) {
107+
if (line.trim()) log.info('cloudflared', line.trim());
108+
}
109+
});
110+
111+
const url = await new Promise<string>((resolve, reject) => {
112+
const timeout = setTimeout(() => {
113+
reject(new Error('터널 URL을 가져오는 데 시간이 초과되었습니다 (60초).'));
114+
}, 60_000);
115+
116+
tunnelInstance.once('url', (tunnelUrl: string) => {
117+
clearTimeout(timeout);
118+
log.success('tunnel', `tunnel active → ${tunnelUrl}`);
119+
resolve(tunnelUrl);
120+
});
121+
122+
tunnelInstance.once('error', (err: Error) => {
123+
clearTimeout(timeout);
124+
log.error('tunnel', `tunnel error: ${err.message}`);
125+
reject(err);
126+
});
127+
128+
tunnelInstance.once('exit', (code: number | null) => {
129+
clearTimeout(timeout);
130+
reject(new Error(`cloudflared 프로세스가 예상치 못하게 종료되었습니다 (code: ${code}).`));
131+
});
132+
});
133+
134+
this._url = url;
135+
136+
// 프로세스 종료 시 상태 초기화 및 로그
137+
tunnelInstance.once('exit', (code: number | null) => {
138+
log.warn('tunnel', `cloudflared exited (code: ${code ?? 'null'})`);
139+
this._running = false;
140+
this._url = null;
141+
this._mode = null;
142+
this._tunnelInstance = null;
143+
});
144+
145+
return { url };
146+
} catch (err) {
147+
this._running = false;
148+
this._mode = null;
149+
this._tunnelInstance = null;
150+
throw err;
151+
}
152+
}
153+
154+
stop(): void {
155+
if (!this._running || !this._tunnelInstance) throw new Error('실행 중인 터널이 없습니다.');
156+
log.info('tunnel', 'stopping tunnel…');
157+
this._tunnelInstance.stop();
158+
this._running = false;
159+
this._url = null;
160+
this._mode = null;
161+
this._tunnelInstance = null;
162+
}
163+
}
164+
165+
export const tunnelManager = new CloudflareTunnelManager();

apps/web/src/styles/admin.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,3 +666,54 @@
666666
padding: 14px 16px;
667667
}
668668
}
669+
670+
/* ── Tunnel ───────────────────────────────────────────────────── */
671+
.admin-badge-ok {
672+
background: color-mix(in srgb, var(--ok) 15%, transparent);
673+
color: var(--ok);
674+
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
675+
}
676+
677+
.admin-tunnel-section {
678+
display: grid;
679+
gap: 12px;
680+
margin-bottom: 4px;
681+
}
682+
683+
.admin-tunnel-url-box {
684+
background: var(--bg-surface);
685+
border: 1px solid var(--border);
686+
border-radius: 10px;
687+
padding: 12px 14px;
688+
display: grid;
689+
gap: 8px;
690+
}
691+
692+
.admin-tunnel-url-label {
693+
font-size: 11px;
694+
font-weight: 600;
695+
color: var(--text-muted);
696+
text-transform: uppercase;
697+
letter-spacing: 0.05em;
698+
}
699+
700+
.admin-tunnel-url-row {
701+
display: flex;
702+
align-items: center;
703+
gap: 10px;
704+
}
705+
706+
.admin-tunnel-url {
707+
font-family: monospace;
708+
font-size: 13px;
709+
color: var(--primary);
710+
word-break: break-all;
711+
flex: 1;
712+
}
713+
714+
.admin-tunnel-notice {
715+
font-size: 12px;
716+
color: var(--text-faint);
717+
margin: 4px 0 0;
718+
line-height: 1.6;
719+
}

0 commit comments

Comments
 (0)