[pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring

- Top-bar calendar widget (status/js/calendar-widget.js): next-appointment
  display, settings modal with CalDAV/ICS source management and a
  Test-connection probe. Follows the weather-widget pattern.
- Backend routes/calendar.js: sources CRUD (secrets in credential manager,
  never in responses), aggregated events across sources with per-source
  error isolation, Radicale provision endpoint, CalDAV discover probe.
- src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded
  RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL,
  EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap.
- src/calendar/caldav-client.js: CalDAV REPORT time-range queries over
  node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap.
- src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates
  a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash
  verified byte-for-byte against openssl passwd -apr1 vectors), stores
  credentials, registers the source. Deploy hook in routes/apps/deploy.js
  wires any Radicale deployed via the App Selector automatically.
- Radicale app template (tomsquest/docker-radicale) in the catalog.
- Tests: 32 new (parser unit, provisioner unit incl. openssl vectors,
  route tests). Full suite 3552 passed; only pre-existing pdf-parse
  billing/invoice failures remain (proven red at base 448f535).
- OpenAPI: 5 calendar paths documented. CHANGELOG entry added.
This commit is contained in:
Hermes committed 2026-10-07 19:57:22 -07:00
1 parent 448f5358bf
commit 74e21c1edc
18 files changed
+1845

No files matched your search

@@ -0,0 +1,166 @@
/**
* DC-161: Radicale auto-provisioner — the zero-config auto-wiring core.
*
* When DashCaddy deploys Radicale through the App Selector, this module:
* 1. Generates a strong calendar password (never shown in a deploy form)
* 2. Writes the Radicale config (htpasswd auth) + htpasswd file into the
* mounted config volume BEFORE the container starts
* 3. Stores the credentials in the DashCaddy credential manager under
* `calendar:radicale:<subdomain>`
* 4. Registers a calendar source so the dashboard calendar widget finds
* it automatically on first render — no user input at all
*
* The htpasswd hash is APR1 (MD5-crypt) — the format Radicale's default
* `radicale.auth.type = htpasswd` accepts, generated with Node's crypto
* (no external tools at deploy time).
*/
'use strict';
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const crypto = require('crypto');
const RADICALE_CONFIG_TEMPLATE = `# Generated by DashCaddy DC-161 auto-provisioner — safe to regenerate
[server]
hosts = 0.0.0.0:5232
[auth]
type = htpasswd
htpasswd_filename = /config/users
htpasswd_encryption = md5
[rights]
type = owner_only
[storage]
filesystem_folder = /data/collections
[web]
type = internal
`;
function generatePassword() {
// 24 chars from a 62-char alphabet ≈ 142 bits — beyond brute-force reach
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = crypto.randomBytes(24);
let out = '';
for (const b of bytes) out += alphabet[b % alphabet.length];
return out;
}
/** APR1 (Apache MD5-crypt) hash — pure Node, matches `openssl passwd -apr1`. */
function apr1Hash(password, salt) {
let saltStr = String(salt || '');
if (!saltStr) {
// Random 8-char salt from the APR1 alphabet when none supplied
const alphabet = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const bytes = crypto.randomBytes(8);
saltStr = Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
}
saltStr = saltStr.slice(0, 8);
const pw = Buffer.from(String(password), 'utf8');
const s = Buffer.from(saltStr, 'ascii');
// Step 2: B = md5(pw + salt + pw)
const bDigest = crypto.createHash('md5').update(pw).update(s).update(pw).digest();
// Step 1 + 3 + 4: ONE rolling hash — pw, "$apr1$", salt, then B folded in
// 16 bytes at a time (tail truncated), then the "weird" loop feeding
// B[0] on odd bits and pw[0] on even bits. (md5(md5(x)||y) ≠ MD5Update —
// the accumulator must CONTINUE, never re-digest.)
const ctx = crypto.createHash('md5').update(pw).update('$apr1$').update(s);
let i = pw.length;
while (i > 0) {
ctx.update(i > 16 ? bDigest : bDigest.subarray(0, i));
i -= 16;
}
// Canonical APR (crypto/apr_md5.c): final is memset to ZERO before the
// weird loop, so odd bits feed a literal 0x00 byte; even bits feed pw[0].
i = pw.length;
while (i > 0) {
ctx.update(i & 1 ? Buffer.from([0]) : pw.subarray(0, 1));
i >>= 1;
}
let final = ctx.digest();
// 1000-iteration round: odd → (pw, final), even → (final, pw).
for (let k = 0; k < 1000; k++) {
const h = crypto.createHash('md5');
if (k & 1) h.update(pw); else h.update(final);
if (k % 3) h.update(s);
if (k % 7) h.update(pw);
if (k & 1) h.update(final); else h.update(pw);
final = h.digest();
}
return `$apr1$${saltStr}$${toBase64Apr1(final)}`;
}
function toBase64Apr1(digest) {
const chars = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const groups = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]];
let out = '';
for (const [a, b, c] of groups) {
let n = (digest[a] << 16) | (digest[b] << 8) | digest[c];
for (let k = 0; k < 4; k++) { out += chars[n & 0x3f]; n >>= 6; }
}
const n = digest[11];
out += chars[n & 0x3f];
out += chars[(n >> 6) & 0x3f];
return out;
}
/**
* Write (idempotently) the Radicale config + htpasswd into the host-side
* config volume directory. Called before container start; if the files
* already exist with the same content, nothing is written.
* @param {string} configDir host path bound to /config inside the container
* @param {{user: string, pass: string}} creds
* @returns {Promise<{written: boolean, configDir: string}>}
*/
async function writeRadicaleConfig(configDir, { user, pass }) {
if (!configDir || path.basename(configDir) === '/') throw new Error('Radicale config dir missing');
fsSync.mkdirSync(configDir, { recursive: true });
const configFile = path.join(configDir, 'config');
const usersFile = path.join(configDir, 'users');
const hash = apr1Hash(pass);
const userLine = `${user}:${hash}\n`;
await fs.writeFile(configFile, RADICALE_CONFIG_TEMPLATE, 'utf8');
await fs.writeFile(usersFile, userLine, { encoding: 'utf8', mode: 0o600 });
return { written: true, configDir };
}
/**
* After a successful Radicale deploy: register the calendar source so the
* widget auto-discovers it, and persist credentials.
* @param {Object} deps
* @returns {Promise<{source: Object}>}
*/
async function registerAutoWiredSource({ calendarStateManager, credentialManager, subdomain, serviceUrl, creds, templateId = 'radicale' }) {
const username = creds.user;
const credKey = `calendar:${templateId}:${subdomain}`;
await credentialManager.store(credKey, JSON.stringify({ user: username, pass: creds.pass }), {
label: `Calendar (${templateId}) — ${subdomain}`, auto: true, createdAt: new Date().toISOString(),
});
const source = {
id: `auto-${templateId}-${subdomain}`,
type: 'caldav',
name: `Calendar (${subdomain})`,
url: `${String(serviceUrl).replace(/\/$/, '')}/${username}/`,
user: username,
autoProvisioned: true,
templateId,
subdomain,
enabled: true,
createdAt: new Date().toISOString(),
};
await calendarStateManager.addItem(source);
return source;
}
module.exports = { generatePassword, apr1Hash, writeRadicaleConfig, registerAutoWiredSource, RADICALE_CONFIG_TEMPLATE };