import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { constants } from "node:fs"; import { spawn } from "node:child_process"; import { randomBytes, randomUUID } from "node:crypto"; import http from "node:http"; import process from "node:process"; const APP_UID = 10001; const APP_GID = 10001; const DATA_DIR = process.env.ORGANIZER_DATA_DIR || "/data"; const PUBLIC_PORT = Number(process.env.PORT || "3000"); const INTERNAL_PORT = Number(process.env.ORGANIZER_INTERNAL_PORT || "3001"); const TONES = ["cyan", "amber", "violet", "emerald", "rose"]; async function readable(path) { try { await access(path, constants.R_OK); return true; } catch { return false; } } async function settingFromFile(environmentName, fileEnvironmentName, defaultFile) { const direct = process.env[environmentName]; if (direct !== undefined) return direct.trim(); const configuredFile = process.env[fileEnvironmentName] || defaultFile; if (await readable(configuredFile)) return (await readFile(configuredFile, "utf8")).trim(); return ""; } function validCalendarUrl(value) { const url = new URL(String(value)); if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) { throw new Error("Every calendar URL must be an HTTPS calendar.google.com basic.ics URL"); } return url.toString(); } function parseCalendarSecret(raw) { if (!raw) return []; let values; try { values = JSON.parse(raw); } catch { throw new Error("Calendar secret must be a JSON array"); } if (!Array.isArray(values) || values.length > 10) throw new Error("Calendar secret must contain at most 10 URLs"); return [...new Set(values.map(validCalendarUrl))]; } async function writeJsonAtomic(path, value) { const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temporary, path); } function dropPrivileges() { if (process.platform === "win32" || typeof process.getuid !== "function" || process.getuid() !== 0) return; process.setgroups([]); process.setgid(APP_GID); process.setuid(APP_UID); } async function prepareDataDirectory(seedUrls) { await mkdir(`${DATA_DIR}/history`, { recursive: true, mode: 0o700 }); const probe = `${DATA_DIR}/.write-test`; try { await writeFile(probe, "ok", { mode: 0o600 }); } catch { throw new Error(`${DATA_DIR} must be writable by uid ${APP_UID}`); } const settingsPath = `${DATA_DIR}/settings.json`; if (!(await readable(settingsPath))) { const now = new Date().toISOString(); await writeJsonAtomic(settingsPath, { schema: 1, updatedAt: now, calendars: seedUrls.map((url, index) => ({ id: randomUUID(), name: "", url, tone: TONES[index % TONES.length], enabled: true, })), }); console.log(`[organizer] created settings.json with ${seedUrls.length} calendar(s) from the startup secret`); } const authPath = `${DATA_DIR}/auth.json`; if (!(await readable(authPath))) { await writeJsonAtomic(authPath, { passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") }); } } const FORWARDING_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded", "x-real-ip"]; const INTERNAL_HEADERS = ["x-hfo-client-ip", "x-hfo-forwarded"]; // The public listener stamps each request with the real TCP peer address so the // app can enforce LAN-only admin access. Client-supplied copies are discarded. function startFrontProxy() { const agent = new http.Agent({ keepAlive: true, maxSockets: 64 }); const server = http.createServer((request, response) => { const headers = { ...request.headers }; for (const name of INTERNAL_HEADERS) delete headers[name]; headers["x-hfo-client-ip"] = String(request.socket.remoteAddress || "").replace(/^::ffff:/, ""); if (FORWARDING_HEADERS.some((name) => request.headers[name] !== undefined)) headers["x-hfo-forwarded"] = "1"; const upstream = http.request( { host: "127.0.0.1", port: INTERNAL_PORT, method: request.method, path: request.url, headers, agent }, (upstreamResponse) => { response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers); upstreamResponse.pipe(response); }, ); upstream.on("error", () => { if (!response.headersSent) response.writeHead(502, { "Content-Type": "text/plain" }); response.end("Upstream unavailable"); }); request.pipe(upstream); }); server.headersTimeout = 20_000; server.requestTimeout = 60_000; server.listen(PUBLIC_PORT, process.env.HOSTNAME || "0.0.0.0"); return server; } async function main() { const calendarSecret = await settingFromFile( "CALENDAR_ICAL_URLS", "CALENDAR_ICAL_URLS_FILE", "/run/secrets/calendar_ical_urls", ); const accessToken = await settingFromFile( "ORGANIZER_ACCESS_TOKEN", "ORGANIZER_ACCESS_TOKEN_FILE", "/run/secrets/organizer_access_token", ); if (accessToken && !/^[A-Za-z0-9._~-]{32,512}$/.test(accessToken)) { throw new Error("ORGANIZER_ACCESS_TOKEN must be empty or 32 to 512 URL-safe characters"); } const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago"; try { new Intl.DateTimeFormat("en-US", { timeZone: organizerTimeZone }).format(new Date()); } catch { throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`); } const seedUrls = parseCalendarSecret(calendarSecret); // Secrets are read; everything from here on runs unprivileged. dropPrivileges(); await prepareDataDirectory(seedUrls); const environment = { ...process.env }; delete environment.CALENDAR_ICAL_URLS; Object.assign(environment, { ORGANIZER_ACCESS_TOKEN: accessToken, ORGANIZER_TIMEZONE: organizerTimeZone, ORGANIZER_DATA_DIR: DATA_DIR, HOSTNAME: "127.0.0.1", PORT: String(INTERNAL_PORT), }); const child = spawn( process.execPath, ["node_modules/next/dist/bin/next", "start", "-H", "127.0.0.1", "-p", String(INTERNAL_PORT)], { cwd: process.cwd(), env: environment, stdio: "inherit" }, ); const server = startFrontProxy(); const forwardSignal = (signal) => { server.close(); if (!child.killed) child.kill(signal); }; process.on("SIGINT", () => forwardSignal("SIGINT")); process.on("SIGTERM", () => forwardSignal("SIGTERM")); child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); }); } main().catch((error) => { console.error(`[organizer] startup failed: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });