Files
jeremy 8715589cbb feat(hobbs-organizer): v1.1.0 password-protected LAN-only admin for calendar links
- /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
2026-11-04 18:55:00 -06:00

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);
});