/** * 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:source:` (unified key — the same key the events * fetcher resolves for manual sources) * 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. Unbiased: rejection-sample // bytes above the last full multiple of the alphabet size (judge r1 polish). const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const limit = Math.floor(256 / alphabet.length) * alphabet.length; let out = ''; while (out.length < 24) { const bytes = crypto.randomBytes(32); for (const b of bytes) { if (b >= limit) continue; // reject biased tail out += alphabet[b % alphabet.length]; if (out.length === 24) break; } } 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 the Radicale config + htpasswd into the host-side config volume * directory. Called BEFORE container start (Radicale reads config at start). * The write is unconditional but content-stable: same inputs produce the * same files, so re-running provisioning converges to the same state. * @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. * * Credential-key convention (DC-161 r2 fix): ALL source credentials — manual * and auto-wired — live at `calendar:source:`. That is the key the * events fetcher resolves, so auto-wired sources authenticate on first fetch. * Idempotent: re-provisioning the same subdomain updates the existing source * (deterministic id `auto--`) instead of duplicating. * * @param {Object} deps * @returns {Promise<{source: Object}>} */ async function registerAutoWiredSource({ calendarStateManager, credentialManager, subdomain, serviceUrl, creds, templateId = 'radicale' }) { const username = creds.user; 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(), }; const credKey = `calendar:source:${source.id}`; await credentialManager.store(credKey, JSON.stringify({ user: username, pass: creds.pass }), { label: `Calendar (${templateId}) — ${subdomain}`, auto: true, createdAt: source.createdAt, }); const existing = await calendarStateManager.findItem(source.id); if (existing) { await calendarStateManager.updateItem(source.id, source); } else { await calendarStateManager.addItem(source); } return source; } module.exports = { generatePassword, apr1Hash, writeRadicaleConfig, registerAutoWiredSource, RADICALE_CONFIG_TEMPLATE };