import { createHash } from 'node:crypto' const API_BASE = 'https://api.cloudflare.com/client/v4' const TRANSFORM_PHASE = 'http_request_transform' const MANAGED_REF_PREFIX = 'zpan_webdav_' const RULESET_NAME = 'ZPan WebDAV URL Rewrite' const RULE_DESCRIPTION_PREFIX = 'Managed by ZPan WebDAV:' const DEFAULT_WORKER_NAME = 'zpan' const VERIFY_ATTEMPTS = 30 const VERIFY_DELAY_MS = 10_000 export function webDavHostname(primaryHostname) { return `dav.${primaryHostname}` } export function findPrimaryWorkerDomain(domains, managedHostnames = new Set()) { const candidates = domains.filter((domain) => !managedHostnames.has(domain.hostname)) const primary = candidates.filter( (candidate) => !candidates.some((other) => candidate.hostname === webDavHostname(other.hostname)), ) if (primary.length === 0) return null if (primary.length > 1) { throw new Error(`Multiple primary Worker Custom Domains found: ${primary.map((domain) => domain.hostname).join(', ')}`) } if (primary[0].hostname.startsWith('dav.')) { throw new Error(`Cannot infer a primary Worker Custom Domain from ${primary[0].hostname}`) } return primary[0] } export function managedRuleRef(hostname) { return `${MANAGED_REF_PREFIX}${createHash('sha256').update(hostname).digest('hex').slice(0, 16)}` } export function buildRewriteRule(hostname) { return { ref: managedRuleRef(hostname), description: `${RULE_DESCRIPTION_PREFIX} ${hostname}`, expression: `http.host eq "${hostname}"`, action: 'rewrite', action_parameters: { uri: { path: { expression: 'concat("/dav", http.request.uri.path)', }, }, }, enabled: true, } } export async function syncCloudflareWebDav({ token, accountId, workerName = DEFAULT_WORKER_NAME, apiFetch = fetch, verifyFetch = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), verifyAttempts = VERIFY_ATTEMPTS, verifyDelayMs = VERIFY_DELAY_MS, }) { if (!token) throw new Error('CLOUDFLARE_API_TOKEN is required') if (!accountId) throw new Error('CLOUDFLARE_ACCOUNT_ID is required') const client = createApiClient({ token, apiFetch }) const domains = await listWorkerDomains(client, accountId) const workerDomains = domains.filter( (domain) => domain.service === workerName && (!domain.environment || domain.environment === 'production'), ) const zones = workerDomainZones(workerDomains) const managedRules = await listManagedRules(client, zones) const managedHostnames = new Set(managedRules.map(({ rule }) => managedRuleHostname(rule))) const primaryDomain = findPrimaryWorkerDomain(workerDomains, managedHostnames) const desiredUrl = primaryDomain ? new URL(`https://${webDavHostname(primaryDomain.hostname)}`) : null let desiredRule = null if (desiredUrl) { const zone = { id: primaryDomain.zone_id, name: primaryDomain.zone_name } desiredRule = await ensureRewriteRule(client, zone, managedRules, desiredUrl.hostname) await ensureWorkerDomain(client, accountId, domains, { hostname: desiredUrl.hostname, service: workerName, zone_id: zone.id, zone_name: zone.name, }) await verifyWebDavDomain(desiredUrl, { verifyFetch, sleep, verifyAttempts, verifyDelayMs }) } const staleRules = managedRules.filter( (managed) => !desiredRule || managed.zone.id !== desiredRule.zone.id || managed.rule.id !== desiredRule.rule.id, ) const staleHostnames = new Set( staleRules .map(({ rule }) => managedRuleHostname(rule)) .filter((hostname) => hostname !== desiredUrl?.hostname), ) let removedDomains = 0 for (const domain of domains) { if (domain.service === workerName && staleHostnames.has(domain.hostname)) { await client.request(`/accounts/${accountId}/workers/domains/${domain.id}`, { method: 'DELETE' }) removedDomains += 1 } } for (const managed of staleRules) { await client.request(`/zones/${managed.zone.id}/rulesets/${managed.rulesetId}/rules/${managed.rule.id}`, { method: 'DELETE', }) } return { hostname: desiredUrl?.hostname ?? null, removedRules: staleRules.length, removedDomains, } } function workerDomainZones(domains) { const zones = new Map() for (const domain of domains) zones.set(domain.zone_id, { id: domain.zone_id, name: domain.zone_name }) return [...zones.values()] } function createApiClient({ token, apiFetch }) { return { async request(path, { method = 'GET', body, allowNotFound = false } = {}) { const response = await apiFetch(`${API_BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }) if (allowNotFound && response.status === 404) return null const payload = await response.json().catch(() => null) if (!response.ok || !payload?.success) { const errors = Array.isArray(payload?.errors) ? payload.errors.map((error) => error.message || error.code).join('; ') : `HTTP ${response.status}` throw new Error(`Cloudflare API ${method} ${path} failed: ${errors}`) } return payload }, } } async function getTransformRuleset(client, zoneId) { const payload = await client.request(`/zones/${zoneId}/rulesets/phases/${TRANSFORM_PHASE}/entrypoint`, { allowNotFound: true, }) return payload?.result ?? null } async function listManagedRules(client, zones) { const managed = [] for (const zone of zones) { const ruleset = await getTransformRuleset(client, zone.id) if (!ruleset) continue for (const rule of ruleset.rules ?? []) { if (rule.ref?.startsWith(MANAGED_REF_PREFIX)) { managedRuleHostname(rule) managed.push({ zone, rulesetId: ruleset.id, rule }) } } } return managed } async function ensureTransformRuleset(client, zone) { const existing = await getTransformRuleset(client, zone.id) if (existing) return existing const payload = await client.request(`/zones/${zone.id}/rulesets`, { method: 'POST', body: { name: RULESET_NAME, description: 'ZPan-managed request rewrites', kind: 'zone', phase: TRANSFORM_PHASE, }, }) return payload.result } async function ensureRewriteRule(client, zone, managedRules, hostname) { const definition = buildRewriteRule(hostname) const existing = managedRules.find( (managed) => managed.zone.id === zone.id && managed.rule.ref === definition.ref, ) if (existing) { if (!sameRule(existing.rule, definition)) { const payload = await client.request( `/zones/${zone.id}/rulesets/${existing.rulesetId}/rules/${existing.rule.id}`, { method: 'PATCH', body: definition }, ) existing.rule = payload.result.rules.find((rule) => rule.ref === definition.ref) ?? { ...definition, id: existing.rule.id, } } return existing } const ruleset = await ensureTransformRuleset(client, zone) const payload = await client.request(`/zones/${zone.id}/rulesets/${ruleset.id}/rules`, { method: 'POST', body: definition, }) const rule = payload.result.rules.find((candidate) => candidate.ref === definition.ref) if (!rule?.id) throw new Error(`Cloudflare did not return the created WebDAV rule for ${hostname}`) return { zone, rulesetId: ruleset.id, rule } } function sameRule(rule, definition) { return ( rule.ref === definition.ref && rule.description === definition.description && rule.expression === definition.expression && rule.action === definition.action && rule.enabled !== false && rule.action_parameters?.uri?.path?.expression === definition.action_parameters.uri.path.expression ) } function managedRuleHostname(rule) { const match = /^http\.host eq "([^"\\]+)"$/.exec(rule.expression ?? '') if (!match) throw new Error(`Managed WebDAV rule ${rule.ref} has an unexpected expression`) return match[1] } async function listWorkerDomains(client, accountId) { const payload = await client.request(`/accounts/${accountId}/workers/domains`) return payload.result } async function ensureWorkerDomain(client, accountId, domains, desired) { const existing = domains.find((domain) => domain.hostname === desired.hostname) if (existing?.service === desired.service && existing.zone_id === desired.zone_id) return if (existing && existing.service !== desired.service) { throw new Error(`${desired.hostname} is already attached to Worker ${existing.service}`) } await client.request(`/accounts/${accountId}/workers/domains`, { method: 'PUT', body: desired }) } async function verifyWebDavDomain(url, { verifyFetch, sleep, verifyAttempts, verifyDelayMs }) { for (let attempt = 1; attempt <= verifyAttempts; attempt += 1) { try { const response = await verifyFetch(url, { method: 'OPTIONS', redirect: 'manual' }) if (response.status === 401 && response.headers.get('WWW-Authenticate') === 'Basic realm="ZPan WebDAV"') { return } } catch (error) { if (attempt === verifyAttempts) throw error } if (attempt < verifyAttempts) await sleep(verifyDelayMs) } throw new Error(`WebDAV custom domain did not become ready: ${url.origin}`) }