#!/usr/bin/env node /* eslint-disable no-console */ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); //const __dirname = path.dirname(__filename); // ============ 환경설정 ============ const REQUIRE_PUBLIC = (process.env.REQUIRE_PUBLIC ?? "true").toLowerCase() === "true"; const FETCH_TIMEOUT_MS = nenv("FETCH_TIMEOUT_MS", 60_000); const IF_LOCKED_WAIT_MS = nenv("IF_LOCKED_WAIT_MS", 10_000); const IF_UPDATED_WAIT_MS = nenv("IF_UPDATED_WAIT_MS", 1_000); const DEFAULT_WAIT_MS = nenv("DEFAULT_WAIT_MS", 8_000); const BASE_SCAN_DEPTH_ONE = (process.env.BASE_SCAN_DEPTH_ONE ?? "true").toLowerCase() === "true"; const RESCAN_INTERVAL_MS = nenv("RESCAN_INTERVAL_MS", 300_000); // 5분 const SHUTDOWN_TIMEOUT_MS = nenv("SHUTDOWN_TIMEOUT_MS", 15_000); const AUTO_RESET_JITTER_MS = nenv("AUTO_RESET_JITTER_MS", 0); // 0초 직후 지연(밀리초) function nenv(key: string, def: number): number { const v = process.env[key]; if (!v) return def; const num = Number(v); return Number.isFinite(num) ? num : def; } // ============ 유틸 ============ type Json = Record; type ProcResult = { result: boolean; updated: boolean; locked: boolean; lastExecuted?: string; }; class CookieJar { #cookies = new Map(); getCookieHeader(): string | undefined { if (this.#cookies.size === 0) { return undefined; } return [...this.#cookies.entries()].map(([key, value]) => `${key}=${value}`).join("; "); } ingestSetCookie(setCookieHeaders: string[]): void { for (const setCookie of setCookieHeaders) { const cookiePart = setCookie.split(";", 1)[0]?.trim(); if (!cookiePart) { continue; } const eqPos = cookiePart.indexOf("="); if (eqPos <= 0) { continue; } const key = cookiePart.slice(0, eqPos).trim(); const value = cookiePart.slice(eqPos + 1).trim(); if (!key) { continue; } if (value === "") { this.#cookies.delete(key); continue; } this.#cookies.set(key, value); } } } function getSetCookieHeaders(headers: Headers): string[] { const headerObj = headers as unknown as { getSetCookie?: () => string[] }; if (typeof headerObj.getSetCookie === "function") { return headerObj.getSetCookie(); } const fallback = headers.get("set-cookie"); if (!fallback) { return []; } return [fallback]; } function nowStr() { const d = new Date(); const pad = (n: number) => n.toString().padStart(2, "0"); const ms = d.getMilliseconds().toString().padStart(3, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${ms}`; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function log(...args: any[]) { console.log(nowStr(), ...args); } async function fileExists(fp: string): Promise { try { await fs.access(fp, fs.constants.F_OK); return true; } catch { return false; } } async function readFileSafe(fp: string): Promise { try { return await fs.readFile(fp, "utf8"); } catch { return null; } } async function listDirsOnceAsync(base: string): Promise { const out: string[] = []; const names = await fs.readdir(base); for (const name of names) { const p = path.join(base, name); try { if ((await fs.stat(p)).isDirectory()) out.push(p); } catch { /* empty */ } } return out; } function joinUrl(base: string, suffix: string): string { if (base.endsWith("/") && suffix.startsWith("/")) return base + suffix.slice(1); if (!base.endsWith("/") && !suffix.startsWith("/")) return base + "/" + suffix; return base + suffix; } // 분 0초 정렬 대기 시간 계산: 지금 시각에서 다음 분의 0초(+지터)까지 남은 ms function msUntilNextMinuteZero(jitterMs = 0): number { const now = Date.now(); const nextMinuteStart = Math.ceil(now / 60_000) * 60_000; const target = nextMinuteStart + Math.max(0, jitterMs); return Math.max(0, target - now); } // ============ 경로/루트 URL ============ const basepath = path.dirname(path.dirname(path.resolve(__filename))); async function parseWebBaseFromCommonPath(base: string): Promise { const jsPath = path.join(base, "d_shared", "common_path.js"); const text = await readFileSafe(jsPath); if (!text) { log("[WARN] common_path.js 읽기 실패 → fallback=http://127.0.0.1"); return "http://127.0.0.1"; } for (const line of text.split(/\r?\n/)) { if (line.includes("root:")) { let v = line.slice(line.indexOf("root:") + 5).trim(); v = v.replace(/^[\s,'"`]+/, "").replace(/[\s,'"`,]+$/, ""); if (v) return v; } } log("[WARN] root: 항목 없음 → fallback=http://127.0.0.1"); return "http://127.0.0.1"; } const webBase = process.env.PUBLIC_CHECK_URL || await parseWebBaseFromCommonPath(basepath); log("BASEPATH =", basepath); log("WEBBASE =", webBase); // ============ 서버 모델 ============ type ServerEntry = { name: string; // servRelPath abs: string; // 절대 경로 isHidden: () => Promise; // .htaccess 존재 여부 procUrl: string; autoresetUrl: string; }; async function scanOnce(): Promise { const dirs = await listDirsOnceAsync(basepath); // 깊이는 1단계만 const out: ServerEntry[] = []; for (const dir of dirs) { if (BASE_SCAN_DEPTH_ONE === false) { // 필요하면 확장 로직 배치 } const dbphp = path.join(dir, "d_setting", "DB.php"); if (!await fileExists(dbphp)) continue; const name = path.relative(basepath, dir); const isHidden = () => fileExists(path.join(dir, ".htaccess")); out.push({ name, abs: dir, isHidden, procUrl: joinUrl(webBase, `/${name}/proc.php`), autoresetUrl:joinUrl(webBase, `/${name}/j_autoreset.php`), }); } return out; } // ============ 공개 접근 체크 ============ async function isPublicReachable(signal?: AbortSignal, cookieJar?: CookieJar): Promise { if (!REQUIRE_PUBLIC) return true; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), Math.min(5_000, FETCH_TIMEOUT_MS)); const composite = anySignal([ctrl.signal, signal].filter(Boolean) as AbortSignal[]); try { const cookieHeader = cookieJar?.getCookieHeader(); const res = await fetch(webBase, { method: "GET", signal: composite, headers: cookieHeader ? { cookie: cookieHeader } : undefined, }); cookieJar?.ingestSetCookie(getSetCookieHeaders(res.headers)); if (!res.ok) { log(`[PUBLIC] 응답 ${res.status} → 비공개로 간주`); return false; } return true; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { if (e?.name !== "AbortError") log(`[PUBLIC] 체크 실패: ${e?.name || e}`); return false; } finally { clearTimeout(timer); } } // ============ HTTP ============ async function httpGetJson(url: string, outerSignal?: AbortSignal, cookieJar?: CookieJar): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); const signal = anySignal([ctrl.signal, outerSignal].filter(Boolean) as AbortSignal[]); const t0 = performance.now(); try { const cookieHeader = cookieJar?.getCookieHeader(); const res = await fetch(url, { signal, cache: "no-store", headers: cookieHeader ? { cookie: cookieHeader } : undefined, }); cookieJar?.ingestSetCookie(getSetCookieHeaders(res.headers)); const dt = Math.round(performance.now() - t0); if (!res.ok) { log("HTTPError:", res.status, url); return null; } try { const data = (await res.json()) as T; log(url, `${dt}ms`); return data; } catch { await res.arrayBuffer().catch(() => { /* empty */ }); log("ParseError: JSON 변환 실패", url); return null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { if (e?.name !== "AbortError") log(e?.name || "Exception", e?.message || String(e), url); return null; } finally { clearTimeout(timer); } } function anySignal(signals: AbortSignal[]): AbortSignal { if (signals.length === 1) return signals[0]; const ctrl = new AbortController(); for (const s of signals) { if (s.aborted) { ctrl.abort(); break; } s.addEventListener("abort", () => ctrl.abort(), { once: true }); } return ctrl.signal; } // ============ 서버 러너 ============ class ServerRunner { #entry: ServerEntry; #task?: Promise; #stopCtrl = new AbortController(); #stopped = false; #lastAutoResetAt = 0; #cookieJar = new CookieJar(); constructor(entry: ServerEntry) { this.#entry = entry; } name() { return this.#entry.name; } start() { if (!this.#task) { log(`[${this.name()}] 루프 시작`); this.#task = this.#run(); } return this.#task; } async stop(): Promise { if (this.#stopped) return; this.#stopped = true; this.#stopCtrl.abort(); try { await this.#task; } catch { /* empty */ } log(`[${this.name()}] 루프 종료`); } async #run() { while (!this.#stopCtrl.signal.aborted) { try { // 공개 접근 체크 const pub = await isPublicReachable(this.#stopCtrl.signal, publicCookieJar); if (!pub) { await delay(15_000, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; } const hidden = await this.#entry.isHidden(); if (hidden) { // ★ 매 분 0초에 맞춰 호출 const waitMs = msUntilNextMinuteZero(AUTO_RESET_JITTER_MS); if (waitMs > 0) { // 신호 안전하게 대기 await delay(waitMs, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); if (this.#stopCtrl.signal.aborted) break; } log(`[${this.name()}] 닫힘 - (정렬) autoreset 호출 @ mm:00${AUTO_RESET_JITTER_MS ? `+${AUTO_RESET_JITTER_MS}ms` : ""}`); await httpGetJson(this.#entry.autoresetUrl, this.#stopCtrl.signal, this.#cookieJar); // 같은 분에 중복 호출 방지: 0초 직후 잠깐 쉬고 다음 루프는 자연스럽게 다음 분으로 정렬됨 await delay(200, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; } // 열림: proc const data = await httpGetJson(this.#entry.procUrl, this.#stopCtrl.signal, this.#cookieJar); if (!data) { await delay(IF_LOCKED_WAIT_MS, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; } if (data.result === false || data.locked === true) { log(`[${this.name()}] result=${data.result} locked=${data.locked} → ${IF_LOCKED_WAIT_MS}ms 대기`); await delay(IF_LOCKED_WAIT_MS, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; } if (data.updated === true) { log(`[${this.name()}] updated=true → ${IF_UPDATED_WAIT_MS}ms 후 재호출 (last=${data.lastExecuted ?? "-"})`); await delay(IF_UPDATED_WAIT_MS, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; } await delay(DEFAULT_WAIT_MS, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { if (this.#stopCtrl.signal.aborted) break; log(`[${this.name()}] 루프 예외:`, e?.message || String(e)); await delay(5_000, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); } } } } // ============ 매니저: 재스캔 & 종료 ============ class DaemonManager { #runners = new Map(); #rescanCtrl = new AbortController(); async start() { log("턴 데몬 시작"); await this.#rescanOnce(); // 초기 스캔 this.#scheduleRescan(); // 주기적 재스캔 } async shutdown(timeoutMs = SHUTDOWN_TIMEOUT_MS) { log("[SHUTDOWN] 신호 수신 → 종료 절차 시작"); this.#rescanCtrl.abort(); const stops: Promise[] = []; for (const r of this.#runners.values()) stops.push(r.stop()); // 타임아웃 대기 const done = Promise.allSettled(stops); const timeout = delay(timeoutMs).then(() => { throw new Error("Shutdown timeout"); }); try { await Promise.race([done, timeout]); } catch { log("[SHUTDOWN] 타임아웃 도달 → 강제 종료"); } log("[SHUTDOWN] 종료 완료"); } #scheduleRescan() { // 반복 타이머(AbortSignal과 함께) const loop = async () => { while (!this.#rescanCtrl.signal.aborted) { await delay(RESCAN_INTERVAL_MS, undefined, { signal: this.#rescanCtrl.signal }).catch(() => { /* empty */ }); if (this.#rescanCtrl.signal.aborted) break; try { await this.#rescanOnce(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any catch (e: any) { log("[RESCAN] 예외:", e?.message || String(e)); } } }; loop().catch(() => { /* empty */ }); } async #rescanOnce() { const entries = await scanOnce(); log(`[RESCAN] 서버 ${entries.length}개 발견`); // 추가/갱신/삭제 판정 const seen = new Set(); for (const e of entries) { seen.add(e.name); if (!this.#runners.has(e.name)) { // 신규 서버 const runner = new ServerRunner(e); this.#runners.set(e.name, runner); runner.start().catch(() => { /* empty */ }); log(`[RESCAN] 추가: ${e.name}`); } else { // 이미 러너 있음 → 경로/URL 변경 가능성 거의 없지만, 필요시 갱신 로직 여기에 } } // 사라진 서버 중지 for (const name of Array.from(this.#runners.keys())) { if (!seen.has(name)) { log(`[RESCAN] 제거: ${name}`); const r = this.#runners.get(name); if (!r) continue; this.#runners.delete(name); r.stop().catch(() => { /* empty */ }); } } } } // ============ 메인 ============ const manager = new DaemonManager(); const publicCookieJar = new CookieJar(); async function main() { // 신호 처리: SIGINT/SIGTERM → graceful shutdown const onSignal = async (sig: NodeJS.Signals) => { log(`[SIGNAL] ${sig} 수신`); // 중복 호출 방지 // eslint-disable-next-line @typescript-eslint/no-explicit-any process.off("SIGINT", onSignal as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any process.off("SIGTERM", onSignal as any); await manager.shutdown().catch(() => { /* empty */ }); process.exit(0); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any process.on("SIGINT", onSignal as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any process.on("SIGTERM", onSignal as any); await manager.start(); } main().catch((e) => { log("치명적 예외:", e); process.exitCode = 1; });