claude-controller 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================
3
+ # Controller Service Daemon
4
+ # FIFO 파이프에서 JSON 메시지를 수신하여 claude -p 를 디스패치하는
5
+ # 상주(persistent) 서비스 데몬입니다.
6
+ # ============================================================
7
+ set -uo pipefail
8
+
9
+ # ── 의존성 로드 ──────────────────────────────────────────────
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ source "${SCRIPT_DIR}/../config.sh"
12
+ source "${SCRIPT_DIR}/../lib/jobs.sh"
13
+ source "${SCRIPT_DIR}/../lib/session.sh"
14
+ source "${SCRIPT_DIR}/../lib/executor.sh"
15
+ source "${SCRIPT_DIR}/../lib/worktree.sh"
16
+ source "${SCRIPT_DIR}/../lib/checkpoint.sh"
17
+
18
+ # ── 서비스 로그 ──────────────────────────────────────────────
19
+ SERVICE_LOG="${LOGS_DIR}/service.log"
20
+
21
+ _log() {
22
+ local level="$1"
23
+ shift
24
+ local ts
25
+ ts=$(date '+%Y-%m-%d %H:%M:%S')
26
+ echo "[${ts}] [${level}] $*" >> "$SERVICE_LOG"
27
+ }
28
+
29
+ _log_info() { _log "INFO" "$@"; }
30
+ _log_warn() { _log "WARN" "$@"; }
31
+ _log_error() { _log "ERROR" "$@"; }
32
+
33
+ # ── 배너 출력 ────────────────────────────────────────────────
34
+ _print_banner() {
35
+ cat <<EOF
36
+ ============================================================
37
+ Controller Service Daemon
38
+ ============================================================
39
+ FIFO : ${FIFO_PATH}
40
+ 로그 : ${SERVICE_LOG}
41
+ 최대 작업 : ${MAX_BACKGROUND_JOBS}
42
+ 관리 기준 : Session ID
43
+ ------------------------------------------------------------
44
+ [대기 중] FIFO 파이프에서 메시지를 수신합니다...
45
+ ============================================================
46
+ EOF
47
+ }
48
+
49
+ # ── 정리 (cleanup) ───────────────────────────────────────────
50
+ cleanup() {
51
+ _log_info "서비스 종료 시작 (PID: $$)"
52
+
53
+ # FIFO 제거
54
+ if [[ -p "$FIFO_PATH" ]]; then
55
+ rm -f "$FIFO_PATH"
56
+ _log_info "FIFO 파이프 제거됨: ${FIFO_PATH}"
57
+ fi
58
+
59
+ # PID 파일 제거
60
+ if [[ -f "$PID_FILE" ]]; then
61
+ rm -f "$PID_FILE"
62
+ _log_info "PID 파일 제거됨: ${PID_FILE}"
63
+ fi
64
+
65
+ # 실행 중인 백그라운드 작업 대기 (최대 5초)
66
+ local remaining
67
+ remaining=$(jobs -rp 2>/dev/null | wc -l | tr -d ' ')
68
+ if [[ "$remaining" -gt 0 ]]; then
69
+ _log_info "백그라운드 작업 ${remaining}개 종료 대기 중..."
70
+ wait 2>/dev/null || true
71
+ fi
72
+
73
+ _log_info "서비스 정상 종료됨"
74
+ echo ""
75
+ echo " [종료] 서비스가 정상적으로 종료되었습니다."
76
+ }
77
+
78
+ # ── 시그널 핸들러 ────────────────────────────────────────────
79
+ _on_signal() {
80
+ _log_warn "시그널 수신 — 서비스를 종료합니다."
81
+ echo ""
82
+ echo " [시그널] 종료 시그널 수신. 정리 중..."
83
+ cleanup
84
+ exit 0
85
+ }
86
+
87
+ trap _on_signal SIGTERM SIGINT SIGHUP
88
+
89
+ # ── 중복 디스패치 방지 ────────────────────────────────────────
90
+ # 최근 디스패치된 프롬프트 해시와 타임스탬프를 기록
91
+ _LAST_DISPATCH_HASH=""
92
+ _LAST_DISPATCH_TIME=0
93
+ _DEDUP_WINDOW_SEC=3 # 동일 프롬프트를 무시하는 시간 창(초)
94
+
95
+ _prompt_hash() {
96
+ printf '%s' "$1" | md5 2>/dev/null || printf '%s' "$1" | md5sum 2>/dev/null | cut -d' ' -f1
97
+ }
98
+
99
+ # ── dispatch_job: JSON 메시지 파싱 후 claude -p 실행 ────────
100
+ dispatch_job() {
101
+ local json_line="$1"
102
+
103
+ # JSON 유효성 검사
104
+ if ! echo "$json_line" | jq empty 2>/dev/null; then
105
+ _log_error "유효하지 않은 JSON: ${json_line:0:200}"
106
+ return 1
107
+ fi
108
+
109
+ # 필드 추출 (callback은 보안상 제거됨 — eval 인젝션 방지)
110
+ local job_uuid prompt cwd use_worktree repo session_raw images_json reuse_wt
111
+ job_uuid=$(echo "$json_line" | jq -r '.id // empty')
112
+ prompt=$(echo "$json_line" | jq -r '.prompt // empty')
113
+ cwd=$(echo "$json_line" | jq -r '.cwd // empty')
114
+ use_worktree=$(echo "$json_line" | jq -r '.worktree // empty')
115
+ repo=$(echo "$json_line" | jq -r '.repo // empty')
116
+ session_raw=$(echo "$json_line" | jq -r '.session // empty')
117
+ images_json=$(echo "$json_line" | jq -c '.images // empty')
118
+ reuse_wt=$(echo "$json_line" | jq -r '.reuse_worktree // empty')
119
+
120
+ if [[ -z "$prompt" ]]; then
121
+ _log_error "프롬프트가 비어있습니다: ${json_line:0:200}"
122
+ return 1
123
+ fi
124
+
125
+ # 고유 ID가 없으면 생성
126
+ if [[ -z "$job_uuid" ]]; then
127
+ job_uuid=$(date '+%s')-$$-$RANDOM
128
+ fi
129
+
130
+ # ── 중복 프롬프트 감지 (짧은 시간 내 동일 프롬프트 무시) ──
131
+ local now
132
+ now=$(date '+%s')
133
+ local phash
134
+ phash=$(_prompt_hash "$prompt")
135
+ local elapsed=$(( now - _LAST_DISPATCH_TIME ))
136
+
137
+ if [[ "$phash" == "$_LAST_DISPATCH_HASH" && $elapsed -lt $_DEDUP_WINDOW_SEC ]]; then
138
+ _log_warn "중복 프롬프트 무시 (${elapsed}초 이내 동일 요청): id=${job_uuid}"
139
+ echo " [무시] 중복 요청이 감지되어 건너뜁니다: ${job_uuid}"
140
+ return 0
141
+ fi
142
+
143
+ _LAST_DISPATCH_HASH="$phash"
144
+ _LAST_DISPATCH_TIME="$now"
145
+
146
+ _log_info "작업 수신: id=${job_uuid} prompt='${prompt:0:80}...'"
147
+
148
+ # 동시 작업 수 제한 확인
149
+ local running_count=0
150
+ for meta_file in "${LOGS_DIR}"/job_*.meta; do
151
+ [[ -f "$meta_file" ]] || continue
152
+ local STATUS=""
153
+ STATUS=$(_get_meta_field "$meta_file" "STATUS")
154
+ if [[ "$STATUS" == "running" ]]; then
155
+ (( running_count++ )) || true
156
+ fi
157
+ done
158
+
159
+ if [[ $running_count -ge $MAX_BACKGROUND_JOBS ]]; then
160
+ _log_warn "최대 동시 작업 수(${MAX_BACKGROUND_JOBS}) 도달 — 작업 거부: ${job_uuid}"
161
+ return 1
162
+ fi
163
+
164
+ # Job 등록
165
+ local job_id
166
+ job_id=$(job_register "$prompt")
167
+
168
+ local out_file="${LOGS_DIR}/job_${job_id}.out"
169
+ local meta_file="${LOGS_DIR}/job_${job_id}.meta"
170
+
171
+ # .meta 파일에 UUID 기록 (원자적 append: temp → rename)
172
+ local _tmp="${meta_file}.tmp.$$"
173
+ { cat "$meta_file"; echo "UUID=${job_uuid}"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
174
+
175
+ # ── Worktree 결정: 재사용(rewind) > 새로 생성 ──
176
+ local wt_path=""
177
+ local effective_repo="${repo:-$TARGET_REPO}"
178
+
179
+ if [[ -n "$reuse_wt" && -d "$reuse_wt" ]]; then
180
+ # Rewind: 기존 worktree 재사용
181
+ wt_path="$reuse_wt"
182
+ _tmp="${meta_file}.tmp.$$"
183
+ { cat "$meta_file"; echo "WORKTREE='${wt_path}'"; echo "REWIND=true"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
184
+ _log_info "Job #${job_id} 기존 워크트리 재사용 (rewind): ${wt_path}"
185
+ elif [[ "$use_worktree" == "true" && -n "$effective_repo" ]]; then
186
+ # 새 worktree 생성
187
+ wt_path=$(worktree_create "$job_id" "$effective_repo" 2>/dev/null)
188
+ if [[ -n "$wt_path" && -d "$wt_path" ]]; then
189
+ _tmp="${meta_file}.tmp.$$"
190
+ { cat "$meta_file"; echo "WORKTREE='${wt_path}'"; echo "REPO='${effective_repo}'"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
191
+ _log_info "Job #${job_id} 워크트리 생성됨: ${wt_path}"
192
+ else
193
+ _log_warn "Job #${job_id} 워크트리 생성 실패 — cwd 모드로 실행"
194
+ wt_path=""
195
+ fi
196
+ fi
197
+
198
+ # ── 이미지 파일을 @path 형태로 프롬프트에 추가 ──
199
+ if [[ -n "$images_json" && "$images_json" != "null" ]]; then
200
+ local img_count
201
+ img_count=$(echo "$images_json" | jq -r 'length' 2>/dev/null)
202
+ if [[ "$img_count" -gt 0 ]] 2>/dev/null; then
203
+ local img_refs=""
204
+ local i=0
205
+ while [[ $i -lt $img_count ]]; do
206
+ local img_path
207
+ img_path=$(echo "$images_json" | jq -r ".[$i]" 2>/dev/null)
208
+ if [[ -n "$img_path" && -f "$img_path" ]]; then
209
+ img_refs="${img_refs} @${img_path}"
210
+ _log_info "Job 이미지 첨부: ${img_path}"
211
+ else
212
+ _log_warn "이미지 파일 없음 (건너뜀): ${img_path}"
213
+ fi
214
+ (( i++ )) || true
215
+ done
216
+ if [[ -n "$img_refs" ]]; then
217
+ prompt="${prompt}${img_refs}"
218
+ fi
219
+ fi
220
+ fi
221
+
222
+ # claude -p 인자 구성 (stream-json + verbose로 실시간 추론 스트리밍)
223
+ local args=()
224
+
225
+ # ── 세션 플래그를 -p 보다 앞에 배치 (CLI 파서 호환성) ──
226
+ if [[ -n "$session_raw" ]]; then
227
+ case "$session_raw" in
228
+ resume:*)
229
+ local resume_sid="${session_raw#resume:}"
230
+ args+=(--resume "$resume_sid")
231
+ _log_info "Job 세션 모드: resume (sid=${resume_sid})"
232
+ ;;
233
+ fork:*)
234
+ local fork_sid="${session_raw#fork:}"
235
+ # Fork: 이전 세션의 결과를 컨텍스트로 주입하여 새 세션으로 실행
236
+ local prev_result="" prev_prompt_text="" best_jid=0
237
+ for mf in "${LOGS_DIR}"/job_*.meta; do
238
+ [[ -f "$mf" ]] || continue
239
+ local sid
240
+ sid=$(_get_meta_field "$mf" "SESSION_ID")
241
+ if [[ "$sid" == "$fork_sid" ]]; then
242
+ local jid
243
+ jid=$(_get_meta_field "$mf" "JOB_ID")
244
+ # 가장 최신 job_id (가장 큰 숫자)를 선택
245
+ if [[ "$jid" -gt "$best_jid" ]] 2>/dev/null; then
246
+ best_jid="$jid"
247
+ prev_prompt_text=$(_get_meta_field "$mf" "PROMPT")
248
+ local of="${LOGS_DIR}/job_${jid}.out"
249
+ if [[ -f "$of" ]]; then
250
+ prev_result=$(grep '"type":"result"' "$of" | tail -1 | jq -r '.result // empty' 2>/dev/null)
251
+ fi
252
+ fi
253
+ fi
254
+ done
255
+ if [[ -n "$prev_result" ]]; then
256
+ # 이전 결과가 너무 길면 앞부분만 사용 (토큰 제한 방지)
257
+ local max_ctx=8000
258
+ if [[ ${#prev_result} -gt $max_ctx ]]; then
259
+ prev_result="${prev_result:0:$max_ctx}
260
+ ... (이전 응답 ${#prev_result}자 중 ${max_ctx}자까지 포함)"
261
+ fi
262
+ prompt="[이전 대화에서 분기 (Fork from session: ${fork_sid:0:8}...)]
263
+ --- 이전 프롬프트 ---
264
+ ${prev_prompt_text}
265
+ --- 이전 응답 ---
266
+ ${prev_result}
267
+ --- 새로운 지시 (이전 컨텍스트를 참고하여 수행) ---
268
+ ${prompt}"
269
+ _log_info "Job 세션 모드: fork (sid=${fork_sid}, job_id=${best_jid}, context=${#prev_result} chars)"
270
+ else
271
+ _log_warn "Job 세션 모드: fork — 이전 세션 결과 없음, 새 세션으로 실행 (sid=${fork_sid})"
272
+ fi
273
+ ;;
274
+ continue)
275
+ args+=(--continue)
276
+ _log_info "Job 세션 모드: continue"
277
+ ;;
278
+ esac
279
+ fi
280
+
281
+ args+=(-p "$prompt")
282
+ args+=(--output-format stream-json)
283
+ args+=(--verbose)
284
+
285
+ if [[ "${SKIP_PERMISSIONS:-false}" == "true" ]]; then
286
+ args+=(--dangerously-skip-permissions)
287
+ elif [[ -n "${DEFAULT_ALLOWED_TOOLS:-}" ]]; then
288
+ args+=(--allowedTools "$DEFAULT_ALLOWED_TOOLS")
289
+ fi
290
+
291
+ if [[ -n "${DEFAULT_MODEL:-}" ]]; then
292
+ args+=(--model "$DEFAULT_MODEL")
293
+ fi
294
+
295
+ if [[ -n "${APPEND_SYSTEM_PROMPT:-}" ]]; then
296
+ args+=(--append-system-prompt "$APPEND_SYSTEM_PROMPT")
297
+ fi
298
+
299
+ # cwd 결정: worktree > JSON cwd > 글로벌 WORKING_DIR > 현재 디렉토리
300
+ local effective_cwd
301
+ if [[ -n "$wt_path" ]]; then
302
+ effective_cwd="$wt_path"
303
+ else
304
+ effective_cwd="${cwd:-${WORKING_DIR:-$(pwd)}}"
305
+ fi
306
+
307
+ # .meta 파일에 CWD 기록
308
+ echo "CWD='${effective_cwd}'" >> "$meta_file"
309
+
310
+ # 백그라운드 서브쉘에서 실행 (cd로 작업 디렉토리 변경)
311
+ (
312
+ _log_info "Job #${job_id} 실행 시작 (uuid=${job_uuid}, cwd=${effective_cwd}, worktree=${wt_path:-none})"
313
+
314
+ cd "$effective_cwd" 2>/dev/null || true
315
+
316
+ # ── Worktree가 있으면 체크포인트 워처 시작 ──
317
+ if [[ -n "$wt_path" && -d "$wt_path" ]]; then
318
+ checkpoint_watcher_loop "$wt_path" "$job_id" "$meta_file" &
319
+ _log_info "Job #${job_id} 체크포인트 워처 시작됨"
320
+ fi
321
+
322
+ # stream-json 출력을 파일에 쓰면서 session_id를 조기 캡처
323
+ # stdbuf/gstdbuf로 라인 버퍼링 강제 (파일 리디렉션 시 블록 버퍼링 방지)
324
+ if command -v stdbuf &>/dev/null; then
325
+ stdbuf -oL "$CLAUDE_BIN" "${args[@]}" < /dev/null > "$out_file" 2>&1 &
326
+ elif command -v gstdbuf &>/dev/null; then
327
+ gstdbuf -oL "$CLAUDE_BIN" "${args[@]}" < /dev/null > "$out_file" 2>&1 &
328
+ else
329
+ "$CLAUDE_BIN" "${args[@]}" < /dev/null > "$out_file" 2>&1 &
330
+ fi
331
+ local claude_pid=$!
332
+
333
+ # 조기 session_id 캡처: 출력 파일 첫 이벤트에서 session_id 추출
334
+ local _sid_captured=""
335
+ local _sid_wait=0
336
+ while kill -0 "$claude_pid" 2>/dev/null && [[ $_sid_wait -lt 30 ]]; do
337
+ if [[ -f "$out_file" && -s "$out_file" ]]; then
338
+ _sid_captured=$(grep -m1 '"session_id"' "$out_file" 2>/dev/null | head -1 | jq -r '.session_id // empty' 2>/dev/null)
339
+ if [[ -n "$_sid_captured" ]]; then
340
+ job_set_session "$job_id" "$_sid_captured"
341
+ session_save "$_sid_captured" "$prompt"
342
+ _log_info "Job #${job_id} 조기 session_id 캡처: ${_sid_captured:0:8}..."
343
+ break
344
+ fi
345
+ fi
346
+ sleep 1
347
+ (( _sid_wait++ )) || true
348
+ done
349
+
350
+ wait "$claude_pid" 2>/dev/null
351
+ local exit_code=$?
352
+
353
+ # 조기 캡처되지 않았다면 최종 result에서 추출
354
+ if [[ -z "$_sid_captured" && -f "$out_file" ]]; then
355
+ local result_line
356
+ result_line=$(grep '"type":"result"' "$out_file" | tail -1)
357
+ if [[ -n "$result_line" ]]; then
358
+ local sid
359
+ sid=$(echo "$result_line" | jq -r '.session_id // empty' 2>/dev/null)
360
+ [[ -n "$sid" ]] && job_set_session "$job_id" "$sid"
361
+ [[ -n "$sid" ]] && session_save "$sid" "$prompt"
362
+ fi
363
+ fi
364
+
365
+ # 상태 갱신 (워처가 이 변경을 감지하고 최종 커밋 후 종료됨)
366
+ if [[ $exit_code -eq 0 ]]; then
367
+ job_mark_done "$job_id"
368
+ _log_info "Job #${job_id} 완료 (exit=0)"
369
+ else
370
+ job_mark_failed "$job_id"
371
+ _log_error "Job #${job_id} 실패 (exit=${exit_code})"
372
+ fi
373
+
374
+ # 체크포인트 워처 종료 대기
375
+ wait 2>/dev/null || true
376
+
377
+ ) &
378
+
379
+ local bg_pid=$!
380
+ job_set_pid "$job_id" "$bg_pid"
381
+
382
+ local wt_label=""
383
+ [[ -n "$wt_path" ]] && wt_label=" [worktree: $(basename "$wt_path")]"
384
+ _log_info "Job #${job_id} 디스패치 완료${wt_label}"
385
+ echo " [디스패치] Job #${job_id} (uuid=${job_uuid})${wt_label} — session_id는 실행 후 자동 할당됩니다."
386
+ }
387
+
388
+ # ── start_service: 데몬 메인 루프 ───────────────────────────
389
+ start_service() {
390
+ # 이미 실행 중인지 확인
391
+ if [[ -f "$PID_FILE" ]]; then
392
+ local existing_pid
393
+ existing_pid=$(cat "$PID_FILE")
394
+ if kill -0 "$existing_pid" 2>/dev/null; then
395
+ echo " [오류] 서비스가 이미 실행 중입니다 (PID: ${existing_pid})"
396
+ echo " 'stop' 명령으로 먼저 종료하세요."
397
+ exit 1
398
+ else
399
+ _log_warn "오래된 PID 파일 발견 (PID: ${existing_pid}). 정리합니다."
400
+ rm -f "$PID_FILE"
401
+ fi
402
+ fi
403
+
404
+ # 디렉토리 보장
405
+ mkdir -p "$LOGS_DIR" "$QUEUE_DIR"
406
+
407
+ # FIFO 생성
408
+ if [[ -p "$FIFO_PATH" ]]; then
409
+ _log_warn "기존 FIFO 파이프 발견. 재사용합니다: ${FIFO_PATH}"
410
+ else
411
+ rm -f "$FIFO_PATH"
412
+ mkfifo "$FIFO_PATH"
413
+ _log_info "FIFO 파이프 생성됨: ${FIFO_PATH}"
414
+ fi
415
+
416
+ # PID 기록
417
+ echo $$ > "$PID_FILE"
418
+ _log_info "서비스 시작 (PID: $$)"
419
+
420
+ # 배너 출력
421
+ _print_banner
422
+
423
+ # ── 메인 수신 루프 ──────────────────────────────────────
424
+ # 외부 while true: FIFO EOF 시 다시 열기
425
+ # 내부 while read: 각 라인을 dispatch_job으로 전달
426
+ while true; do
427
+ while IFS= read -r line; do
428
+ # 빈 줄 무시
429
+ [[ -z "$line" ]] && continue
430
+ # 주석 무시
431
+ [[ "$line" == \#* ]] && continue
432
+
433
+ dispatch_job "$line" || true
434
+ done < "$FIFO_PATH"
435
+
436
+ # FIFO EOF — 모든 writer가 닫힘. 재오픈 대기.
437
+ _log_info "FIFO EOF 감지. 파이프를 다시 엽니다..."
438
+ done
439
+ }
440
+
441
+ # ── stop_service: 외부에서 호출하여 서비스 종료 ──────────────
442
+ stop_service() {
443
+ if [[ ! -f "$PID_FILE" ]]; then
444
+ echo " [오류] 실행 중인 서비스를 찾을 수 없습니다."
445
+ return 1
446
+ fi
447
+
448
+ local pid
449
+ pid=$(cat "$PID_FILE")
450
+
451
+ if kill -0 "$pid" 2>/dev/null; then
452
+ echo " [종료] 서비스에 SIGTERM 전송 (PID: ${pid})..."
453
+ kill "$pid"
454
+ # 종료 대기 (최대 10초)
455
+ local waited=0
456
+ while kill -0 "$pid" 2>/dev/null && [[ $waited -lt 10 ]]; do
457
+ sleep 1
458
+ (( waited++ )) || true
459
+ done
460
+
461
+ if kill -0 "$pid" 2>/dev/null; then
462
+ echo " [경고] 정상 종료 실패. SIGKILL 전송..."
463
+ kill -9 "$pid" 2>/dev/null
464
+ rm -f "$PID_FILE" "$FIFO_PATH"
465
+ fi
466
+
467
+ echo " [완료] 서비스가 종료되었습니다."
468
+ else
469
+ echo " [정보] 프로세스가 이미 종료되어 있습니다. PID 파일을 정리합니다."
470
+ rm -f "$PID_FILE"
471
+ fi
472
+ }
473
+
474
+ # ── 메인 진입점 ──────────────────────────────────────────────
475
+ case "${1:-start}" in
476
+ start)
477
+ start_service
478
+ ;;
479
+ stop)
480
+ stop_service
481
+ ;;
482
+ restart)
483
+ stop_service 2>/dev/null || true
484
+ sleep 1
485
+ start_service
486
+ ;;
487
+ status)
488
+ if [[ -f "$PID_FILE" ]]; then
489
+ pid=$(cat "$PID_FILE")
490
+ if kill -0 "$pid" 2>/dev/null; then
491
+ echo " [실행 중] PID: ${pid}, FIFO: ${FIFO_PATH}"
492
+ else
493
+ echo " [중지됨] 프로세스 없음 (오래된 PID 파일: ${pid})"
494
+ fi
495
+ else
496
+ echo " [중지됨] 서비스가 실행 중이지 않습니다."
497
+ fi
498
+ ;;
499
+ *)
500
+ echo "사용법: $0 {start|stop|restart|status}"
501
+ exit 1
502
+ ;;
503
+ esac
package/web/auth.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ Controller Service — 토큰 기반 인증 모듈
3
+
4
+ 서버 시작 시 랜덤 토큰을 생성하여 파일에 저장하고 터미널에 출력한다.
5
+ 모든 API 요청은 이 토큰을 Authorization 헤더로 포함해야 한다.
6
+ 로컬 머신의 터미널을 볼 수 있는 사람만 토큰을 획득할 수 있으므로
7
+ CSRF, DNS Rebinding 등 원격 공격을 차단한다.
8
+ """
9
+
10
+ import secrets
11
+ from pathlib import Path
12
+
13
+ from config import DATA_DIR
14
+
15
+ TOKEN_FILE = DATA_DIR / "auth_token"
16
+ _cached_token: str | None = None
17
+
18
+
19
+ def generate_token() -> str:
20
+ """새 토큰을 생성하고 파일에 저장한다."""
21
+ global _cached_token
22
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
23
+ token = secrets.token_urlsafe(32)
24
+ TOKEN_FILE.write_text(token, "utf-8")
25
+ # 토큰 파일 권한을 소유자만 읽기/쓰기로 제한
26
+ TOKEN_FILE.chmod(0o600)
27
+ _cached_token = token
28
+ return token
29
+
30
+
31
+ def get_token() -> str:
32
+ """현재 토큰을 반환한다. 없으면 생성한다."""
33
+ global _cached_token
34
+ if _cached_token:
35
+ return _cached_token
36
+ if TOKEN_FILE.exists():
37
+ _cached_token = TOKEN_FILE.read_text("utf-8").strip()
38
+ if _cached_token:
39
+ return _cached_token
40
+ return generate_token()
41
+
42
+
43
+ def verify_token(provided: str) -> bool:
44
+ """제공된 토큰이 유효한지 검증한다. (타이밍 공격 방지를 위해 secrets.compare_digest 사용)"""
45
+ expected = get_token()
46
+ return secrets.compare_digest(provided, expected)