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:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user