Files
core/src/daemon.ts
T

363 lines
14 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable no-console */
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
// ============ 환경설정 ============
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 AUTO_RESET_PERIOD_MS = nenv("AUTO_RESET_PERIOD_MS", 60_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);
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<string, unknown>;
type ProcResult = {
result: boolean;
updated: boolean;
locked: boolean;
lastExecuted?: string;
};
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); }
function fileExists(fp: string): boolean {
try { fs.accessSync(fp, fs.constants.F_OK); return true; } catch { return false; }
}
function readFileSafe(fp: string): string | null {
try { return fs.readFileSync(fp, "utf8"); } catch { return null; }
}
function listDirsOnce(base: string): string[] {
const out: string[] = [];
for (const name of fs.readdirSync(base)) {
const p = path.join(base, name);
try { if (fs.statSync(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;
}
// ============ 경로/루트 URL ============
const basepath = path.dirname(path.dirname(path.resolve(__filename)));
function parseWebBaseFromCommonPath(base: string): string {
const jsPath = path.join(base, "d_shared", "common_path.js");
const text = 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 || parseWebBaseFromCommonPath(basepath);
log("BASEPATH =", basepath);
log("WEBBASE =", webBase);
// ============ 서버 모델 ============
type ServerEntry = {
name: string; // servRelPath
abs: string; // 절대 경로
isHidden: () => boolean; // .htaccess 존재 여부
hasAutoReset: boolean; // j_autoreset.php 파일 존재
procUrl: string;
autoresetUrl: string | null;
};
function scanOnce(): ServerEntry[] {
const dirs = listDirsOnce(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 (!fileExists(dbphp)) continue;
const name = path.relative(basepath, dir);
const isHidden = () => fileExists(path.join(dir, ".htaccess"));
const resetAbs = path.join(dir, "j_autoreset.php");
const hasAutoReset = fileExists(resetAbs);
out.push({
name,
abs: dir,
isHidden,
hasAutoReset,
procUrl: joinUrl(webBase, `/${name}/proc.php`),
autoresetUrl: hasAutoReset ? joinUrl(webBase, `/${name}/j_autoreset.php`) : null,
});
}
return out;
}
// ============ 공개 접근 체크 ============
async function isPublicReachable(signal?: AbortSignal): Promise<boolean> {
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 res = await fetch(webBase, { method: "GET", signal: composite });
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<T extends Json>(url: string, outerSignal?: AbortSignal): Promise<T | null> {
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 res = await fetch(url, { signal, cache: "no-store" });
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<void>;
#stopCtrl = new AbortController();
#stopped = false;
#lastAutoResetAt = 0;
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<void> {
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);
if (!pub) { await delay(15_000, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ }); continue; }
const hidden = this.#entry.isHidden();
if (hidden) {
// 닫힘: autoreset
if (this.#entry.autoresetUrl) {
const now = Date.now();
if (now - this.#lastAutoResetAt >= AUTO_RESET_PERIOD_MS) {
log(`[${this.name()}] 닫힘 - autoreset 호출`);
await httpGetJson<Json>(this.#entry.autoresetUrl, this.#stopCtrl.signal);
this.#lastAutoResetAt = now;
}
} else {
log(`[${this.name()}] 닫힘 - autoreset 없음`);
}
await delay(1000, undefined, { signal: this.#stopCtrl.signal }).catch(() => { /* empty */ });
continue;
}
// 열림: proc
const data = await httpGetJson<ProcResult>(this.#entry.procUrl, this.#stopCtrl.signal);
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<string, ServerRunner>();
#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<void>[] = [];
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 = scanOnce();
log(`[RESCAN] 서버 ${entries.length}개 발견`);
// 추가/갱신/삭제 판정
const seen = new Set<string>();
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();
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;
});