441 lines
16 KiB
JavaScript
441 lines
16 KiB
JavaScript
#!/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<string, unknown>;
|
|
type ProcResult = {
|
|
result: boolean;
|
|
updated: boolean;
|
|
locked: boolean;
|
|
lastExecuted?: string;
|
|
};
|
|
|
|
class CookieJar {
|
|
#cookies = new Map<string, string>();
|
|
|
|
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<boolean> {
|
|
try { await fs.access(fp, fs.constants.F_OK); return true; } catch { return false; }
|
|
}
|
|
|
|
async function readFileSafe(fp: string): Promise<string | null> {
|
|
try { return await fs.readFile(fp, "utf8"); } catch { return null; }
|
|
}
|
|
|
|
async function listDirsOnceAsync(base: string): Promise<string[]> {
|
|
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<string> {
|
|
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<boolean>; // .htaccess 존재 여부
|
|
procUrl: string;
|
|
autoresetUrl: string;
|
|
};
|
|
|
|
async function scanOnce(): Promise<ServerEntry[]> {
|
|
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<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 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<T extends Json>(url: string, outerSignal?: AbortSignal, cookieJar?: CookieJar): 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 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<void>;
|
|
#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<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, 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<Json>(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<ProcResult>(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<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 = await 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();
|
|
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;
|
|
});
|