8715589cbb
- /admin: first-visit password setup (scrypt), signed 30-day session cookie, calendars add/remove/reorder/rename/color/hide, masked URLs, per-feed test, history (20) with restore; /photo-admin -> /admin#photos placeholder - settings in local /data (settings.json + auth.json + history/), seeded once from the calendar secret; dashboard + health read from settings - start.mjs drops privileges after reading secrets and runs a front proxy that stamps the real client IP; admin refuses non-LAN and proxied requests - deploy: HOBBS_DATA_DIR=/var/lib/hobbs-organizer/data, image 1.1.0 (1.0.0 kept) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
187 lines
6.6 KiB
JavaScript
187 lines
6.6 KiB
JavaScript
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);
|
|
});
|