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
This commit is contained in:
2026-11-04 18:55:00 -06:00
parent e9c7e9df1a
commit 8715589cbb
28 changed files with 1532 additions and 380 deletions
@@ -0,0 +1,15 @@
// Clears the admin password so the next visit to /admin (from the LAN) can set a new one.
// Also rotates the session secret, signing out every existing admin session.
// Usage: docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs
import { randomBytes } from "node:crypto";
import { rename, writeFile } from "node:fs/promises";
const path = `${process.env.ORGANIZER_DATA_DIR || "/data"}/auth.json`;
const temporary = `${path}.reset.tmp`;
await writeFile(
temporary,
`${JSON.stringify({ passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") }, null, 2)}\n`,
{ mode: 0o600 },
);
await rename(temporary, path);
console.log("Admin password cleared. Visit /admin from the LAN to set a new one.");
@@ -1,8 +1,17 @@
import { access, readFile } from "node:fs/promises";
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);
@@ -12,35 +21,109 @@ async function readable(path) {
}
}
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile, required = 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();
if (required) throw new Error(`${environmentName} is required; create ${configuredFile}`);
return "";
}
function validateCalendarConfiguration(raw) {
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 < 1 || values.length > 10) {
throw new Error("Calendar secret must contain between 1 and 10 URLs");
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 normalized = values.map((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();
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);
});
return JSON.stringify([...new Set(normalized)]);
server.headersTimeout = 20_000;
server.requestTimeout = 60_000;
server.listen(PUBLIC_PORT, process.env.HOSTNAME || "0.0.0.0");
return server;
}
async function main() {
@@ -48,7 +131,6 @@ async function main() {
"CALENDAR_ICAL_URLS",
"CALENDAR_ICAL_URLS_FILE",
"/run/secrets/calendar_ical_urls",
true,
);
const accessToken = await settingFromFile(
"ORGANIZER_ACCESS_TOKEN",
@@ -64,32 +146,30 @@ async function main() {
} catch {
throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`);
}
const seedUrls = parseCalendarSecret(calendarSecret);
const environment = {
...process.env,
CALENDAR_ICAL_URLS: validateCalendarConfiguration(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,
HOSTNAME: process.env.HOSTNAME || "0.0.0.0",
PORT: process.env.PORT || "3000",
};
const childOptions = {
cwd: process.cwd(),
env: environment,
stdio: "inherit",
};
if (process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() === 0) {
childOptions.uid = 10001;
childOptions.gid = 10001;
}
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", environment.HOSTNAME, "-p", environment.PORT],
childOptions,
["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"));