Files
dashcaddy/dashcaddy-api/src/calendar/radicale-provisioner.js
T
Hermes f64a3cda7d [pending-judge] fix(DC-161 r4): address judge r3 blockers
All three r3 blocking issues fixed + polish applied:

- Radicale provision route now FAILS CLOSED (409) unless the target container
  is present, inspectable, and its image matches /radicale/i. Missing
  containerId, failed inspect, or non-Radicale image → nothing is written to
  the shared config and no source/credential is registered. 4 new tests cover
  the three refusal paths + the verified-image happy path.
- ICS feed fetch now streams with a hard 5 MiB body cap (same bound as the
  CalDAV path) via readBodyCapped(); oversized feeds reject mid-stream and
  degrade to per-source error. Test covers a >5 MiB feed end-to-end.
- Verifier mode separation: `--verify` runs the same checks fully read-only
  (all output under mktemp -d in /tmp, transcript printed to stdout, scratch
  removed on exit — proven rc=0 with zero repo/tmp-side writes); default
  transcript mode is explicitly declared a TRANSCRIPT GENERATOR in its header
  and writes only under the script's own directory.
- Full-suite count assertions replaced with the documented invariant
  (failed ∈ [5,7], failing set == known-flaky billing suite, extras pass
  isolated rerun) — judge r3 polish.
- Stale credential-key comment in radicale-provisioner.js header corrected.

Calendar suites: 43 passed (38 + 5 new). Perf note: readBodyCapped streams;
no full-buffer read of unbounded bodies on the ICS path anymore.
2026-10-07 20:59:17 -07:00

190 lines
6.9 KiB
JavaScript

/**
* 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:<source.id>` (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:<source.id>`. 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-<templateId>-<subdomain>`) 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 };